"""自绘界面控件:专业电平表、实时示波器、响度条、录音按钮、状态标签。 全部用 QPainter 手绘,好处是刷新率与刻度都能精确控制, 并且不受样式表在自定义绘制上的限制。 """ from __future__ import annotations import math import numpy as np from PyQt5.QtCore import QPointF, QRect, QRectF, Qt, pyqtSignal from PyQt5.QtGui import (QBrush, QColor, QFontMetrics, QLinearGradient, QPainter, QPen, QPolygonF) from PyQt5.QtWidgets import QSizePolicy, QWidget from . import dsp # ------------------------------------------------------------- 设计变量 BG = "#0F1218" PANEL = "#161A22" PANEL_2 = "#1D222C" BORDER = "#2A313D" TEXT = "#E8ECF4" TEXT_DIM = "#8B94A7" ACCENT = "#4EA1FF" REC = "#FF4757" OK = "#37D67A" WARN = "#FFB020" CRIT = "#FF5C5C" DB_MIN = -60.0 DB_MAX = 6.0 def db_x(db: float, width: float, db_min: float = DB_MIN, db_max: float = 0.0) -> float: """dB -> 像素(线性刻度,0 dBFS 贴右端)。""" v = (float(db) - db_min) / (db_max - db_min) return max(0.0, min(1.0, v)) * width class LevelMeter(QWidget): """多通道电平表:RMS 条 + 峰值保持 + 削波锁存 + dB 刻度。""" SCALE = [0, -3, -6, -12, -18, -24, -36, -48, -60] def __init__(self, parent=None, *, channels: int = 2, compact: bool = False): super().__init__(parent) self.channels = channels self.compact = compact self._rms: list[float] = [DB_MIN] * channels self._peak: list[float] = [DB_MIN] * channels self._hold: list[float] = [DB_MIN] * channels self._tp: list[float] = [DB_MIN] * channels self._clip: list[bool] = [False] * channels self._interfaces: float = float("-inf") self._labels = ["L", "R", "3", "4", "5", "6", "7", "8"] self.setMinimumHeight(self._preferred_height()) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) def _preferred_height(self) -> int: per = 20 if self.compact else 30 return per * max(1, self.channels) + 26 def set_channels(self, channels: int) -> None: self.channels = max(1, int(channels)) self._rms = [DB_MIN] * self.channels self._peak = [DB_MIN] * self.channels self._hold = [DB_MIN] * self.channels self._tp = [DB_MIN] * self.channels self._clip = [False] * self.channels self.setMinimumHeight(self._preferred_height()) self.update() def update_levels(self, snap: dsp.MeterSnapshot) -> None: n = self.channels self._rms = (list(snap.rms_db) + [DB_MIN] * n)[:n] self._peak = (list(snap.peak_db) + [DB_MIN] * n)[:n] self._hold = (list(snap.hold_db) + [DB_MIN] * n)[:n] self._tp = ([v if v > -200 else DB_MIN for v in snap.true_peak_db] + [DB_MIN] * n)[:n] self._clip = (list(snap.clipped) + [False] * n)[:n] self._interfaces = snap.integrated_lufs self.update() def reset(self) -> None: self._rms = [DB_MIN] * self.channels self._peak = [DB_MIN] * self.channels self._hold = [DB_MIN] * self.channels self._tp = [DB_MIN] * self.channels self._clip = [False] * self.channels self.update() # ------------------------------------------------------------ painting def paintEvent(self, _event) -> None: # noqa: N802 p = QPainter(self) p.setRenderHint(QPainter.Antialiasing, True) w, h = self.width(), self.height() fm = QFontMetrics(self.font()) scale_w = 34 bar_right = w - scale_w - 6 label_w = 26 if self.channels > 2 else 22 bar_left = label_w + 4 bar_w = max(10.0, bar_right - bar_left) top = 4 footer = 20 per = (h - top - footer) / self.channels bar_h = max(8.0, per - (6 if not self.compact else 3)) # 底部统一说明 p.setPen(QColor(TEXT_DIM)) p.setFont(self.font()) tp_max = max(self._tp) if self._tp else DB_MIN intf = ("—" if not math.isfinite(self._interfaces) else f"{self._interfaces:+.1f}") p.drawText(QRect(2, h - footer + 1, w - 4, footer - 2), Qt.AlignLeft | Qt.AlignVCenter, f"真峰值 {tp_max:+.1f} dBTP 整体响度 {intf} LUFS") for ch in range(self.channels): y = top + ch * per bar = QRectF(bar_left, y, bar_w, bar_h) # 轨道 p.setPen(Qt.NoPen) p.setBrush(QColor(PANEL_2)) p.drawRoundedRect(bar, 3, 3) rms_x = db_x(self._rms[ch], bar_w) peak_x = db_x(self._peak[ch], bar_w) hold_x = db_x(self._hold[ch], bar_w) # 峰值填充(暗一些)+ RMS 填充(亮) if peak_x > 0.5: p.setBrush(QColor(ACCENT).darker(190)) p.drawRoundedRect(QRectF(bar_left, y, peak_x, bar_h), 2, 2) if rms_x > 0.5: grad = QLinearGradient(bar_left, 0, bar_left + bar_w, 0) grad.setColorAt(0.0, QColor("#1FA97B")) grad.setColorAt(max(0.001, db_x(-18, 1.0) - 0.001), QColor("#37D67A")) grad.setColorAt(db_x(-6, 1.0), QColor("#FFD24A")) grad.setColorAt(db_x(-1, 1.0), QColor("#FF6B5A")) grad.setColorAt(1.0, QColor("#FF4757")) p.setBrush(QBrush(grad)) p.drawRoundedRect(QRectF(bar_left, y, rms_x, bar_h), 2, 2) # 刻度线 p.setPen(QPen(QColor(255, 255, 255, 26), 1)) for mark in self.SCALE: mx = bar_left + db_x(mark, bar_w) p.drawLine(QPointF(mx, y), QPointF(mx, y + bar_h)) # 峰值保持 if hold_x > 1: p.setPen(QPen(QColor("#F2F5FA"), 2)) p.drawLine(QPointF(bar_left + hold_x, y - 1), QPointF(bar_left + hold_x, y + bar_h + 1)) # 削波锁存 if self._clip[ch]: p.setPen(Qt.NoPen) p.setBrush(QColor(REC)) p.drawRoundedRect(QRectF(bar_left + bar_w - 6, y, 6, bar_h), 2, 2) # 通道标记 p.setPen(QColor(TEXT if self._peak[ch] > DB_MIN + 1 else TEXT_DIM)) p.drawText(QRect(2, int(y), label_w, int(bar_h)), Qt.AlignLeft | Qt.AlignVCenter, self._labels[ch] if ch < len(self._labels) else str(ch + 1)) # 刻度文字(仅每档首行画一次) p.setPen(QColor(TEXT_DIM)) p.setFont(self.font()) if ch == 0: for mark in (0, -6, -18, -36, -60): mx = bar_left + db_x(mark, bar_w) txt = "0" if mark == 0 else str(mark) tw = fm.horizontalAdvance(txt) p.drawText(QRectF(mx - tw / 2, top - 2, tw + 2, 12), Qt.AlignCenter, txt) # 当前峰值数字 val = self._peak[ch] p.setPen(QColor(CRIT if self._clip[ch] else (WARN if val > -2 else TEXT))) p.drawText(QRect(int(bar_right) + 4, int(y), scale_w, int(bar_h)), Qt.AlignRight | Qt.AlignVCenter, "−∞" if val <= DB_MIN + 0.5 else f"{val:.1f}") p.end() class Scope(QWidget): """实时波形示波器:绘制最近一段的 min/max 包络。""" def __init__(self, parent=None, *, seconds: float = 1.0): super().__init__(parent) self.seconds = seconds self.samplerate = 48000 self._data = np.zeros((0, 2), dtype=np.float32) self._recording = False self.setMinimumHeight(150) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) def set_format(self, samplerate: int, channels: int) -> None: self.samplerate = int(samplerate) if self._data.shape[1] != channels: self._data = np.zeros((0, channels), dtype=np.float32) self.update() def update_data(self, data: np.ndarray, recording: bool) -> None: self._data = data self._recording = recording self.update() def paintEvent(self, _event) -> None: # noqa: N802 p = QPainter(self) p.setRenderHint(QPainter.Antialiasing, False) w, h = self.width(), self.height() p.fillRect(0, 0, w, h, QColor(PANEL)) p.setPen(QPen(QColor(BORDER), 1)) p.drawRect(0, 0, w - 1, h - 1) ch = max(1, self._data.shape[1] if self._data.size else 2) lanes = min(2, ch) lane_h = h / lanes cols = max(64, min(2048, w)) # 网格 p.setPen(QPen(QColor(255, 255, 255, 18), 1)) step = max(1, int(self.seconds * 10)) # 100 ms 一条 px_per_div = w / max(1.0, self.seconds * 10) for i in range(step + 1): x = w - i * px_per_div if x < 0: break p.drawLine(QPointF(x, 0), QPointF(x, h)) for lane in range(lanes): y0 = lane * lane_h yc = y0 + lane_h / 2 p.setPen(QPen(QColor(255, 255, 255, 40), 1)) p.drawLine(QPointF(0, yc), QPointF(w, yc)) # ±0.5 参考线 p.setPen(QPen(QColor(255, 255, 255, 16), 1)) for amp in (0.5, -0.5): y = yc - amp * (lane_h / 2 - 3) p.drawLine(QPointF(0, y), QPointF(w, y)) if self._data.size == 0: p.setPen(QColor(TEXT_DIM)) p.drawText(self.rect(), Qt.AlignCenter, "等待录音…" if not self._recording else "正在采集…") p.end() return n = self._data.shape[0] src_ch = [0] if ch == 1 else [0, 1] per = max(1, n // cols) usable = (n // per) * per for lane in range(lanes): c = src_ch[min(lane, len(src_ch) - 1)] col = self._data[:usable, c] if usable else self._data[:, c] if usable: blk = col.reshape(-1, per) mn = blk.min(axis=1) mx = blk.max(axis=1) else: mn = mx = col y0 = lane * lane_h yc = y0 + lane_h / 2 scale = (lane_h / 2 - 3) grid = np.arange(mn.size) * (w / max(1, mn.size)) p.setPen(QPen(QColor(ACCENT) if lane == 0 else QColor("#5AD1A8"), 1)) for i in range(mn.size): x = float(grid[i]) top = yc - float(mx[i]) * scale bot = yc - float(mn[i]) * scale if bot - top < 1.0: top, bot = top - 0.5, bot + 0.5 p.drawLine(QPointF(x, top), QPointF(x, bot)) p.setPen(QColor(TEXT_DIM)) p.drawText(QRect(6, 2, w - 12, 14), Qt.AlignLeft, f"{self.seconds:.1f} 秒窗口 · {self.samplerate} Hz") p.end() class LoudnessBar(QWidget): """BS.1770 响度条:瞬时 / 短时 / 整体 + 目标线。""" def __init__(self, parent=None, *, minimum: float = -40.0, maximum: float = 0.0): super().__init__(parent) self.minimum = minimum self.maximum = maximum self.target = -16.0 self._momentary = float("-inf") self._short_term = float("-inf") self._integrated = float("-inf") self.setMinimumHeight(54) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) def set_target(self, target: float) -> None: self.target = float(target) self.update() def update_levels(self, momentary: float, short_term: float, integrated: float) -> None: self._momentary, self._short_term, self._integrated = \ momentary, short_term, integrated self.update() def _x(self, lufs: float, w: float) -> float: if not math.isfinite(lufs): return 0.0 v = (lufs - self.minimum) / (self.maximum - self.minimum) return max(0.0, min(1.0, v)) * w def paintEvent(self, _event) -> None: # noqa: N802 p = QPainter(self) p.setRenderHint(QPainter.Antialiasing, True) w, h = self.width(), self.height() bar = QRectF(0, 22, w, 14) p.setPen(Qt.NoPen) p.setBrush(QColor(PANEL_2)) p.drawRoundedRect(bar, 3, 3) # 瞬时响度填充 mx = self._x(self._momentary, w) if mx > 0.5: grad = QLinearGradient(0, 0, w, 0) grad.setColorAt(0.0, QColor("#1D5FA8")) grad.setColorAt(0.65, QColor(ACCENT)) grad.setColorAt(0.9, QColor("#7BD3A0")) grad.setColorAt(1.0, QColor("#FFD24A")) p.setBrush(QBrush(grad)) p.drawRoundedRect(QRectF(0, 22, mx, 14), 3, 3) # 短时(亮度更高的一条细线) sx = self._x(self._short_term, w) if sx > 0.5: p.setBrush(QColor(255, 255, 255, 190)) p.drawRect(QRectF(0, 25, sx, 2)) # 整体响度三角标 ix = self._x(self._integrated, w) if ix > 0.0: tri = QPolygonF([QPointF(ix, 19), QPointF(ix - 5, 11), QPointF(ix + 5, 11)]) p.setBrush(QColor("#FFD24A")) p.drawPolygon(tri) # 目标线 tx = self._x(self.target, w) p.setPen(QPen(QColor(OK), 1, Qt.DashLine)) p.drawLine(QPointF(tx, 8), QPointF(tx, 44)) # 刻度 p.setPen(QColor(TEXT_DIM)) p.setFont(self.font()) for val in range(int(self.minimum), int(self.maximum) + 1, 5): x = self._x(val, w) p.drawLine(QPointF(x, 37), QPointF(x, 41)) tw = QFontMetrics(self.font()).horizontalAdvance(str(val)) p.drawText(QRectF(x - tw / 2, 40, tw + 2, 12), Qt.AlignCenter, str(val)) def shown(v: float) -> str: return "—" if not math.isfinite(v) else f"{v:.1f}" p.setPen(QColor(TEXT)) p.drawText(QRect(0, 2, w, 14), Qt.AlignLeft, f"瞬时 {shown(self._momentary)} LUFS " f"短时 {shown(self._short_term)} " f"整体 {shown(self._integrated)}") p.setPen(QColor(TEXT_DIM)) p.drawText(QRect(0, 2, w, 14), Qt.AlignRight, f"目标 {self.target:.0f} LUFS") p.end() class RecordButton(QWidget): """圆形录音按钮(带脉冲光晕)。""" clicked = pyqtSignal() def __init__(self, parent=None, *, diameter: int = 74): super().__init__(parent) self.d = diameter self.setFixedSize(diameter, diameter) self.state = "idle" # idle | recording | paused self._pulse = 0.0 self.setCursor(Qt.PointingHandCursor) self.setToolTip("开始 / 停止录音(空格)") def set_state(self, state: str) -> None: self.state = state self.update() def set_pulse(self, phase: float) -> None: self._pulse = phase if self.state != "idle": self.update() def mousePressEvent(self, event) -> None: # noqa: N802 if event.button() == Qt.LeftButton: self.clicked.emit() def paintEvent(self, _event) -> None: # noqa: N802 p = QPainter(self) p.setRenderHint(QPainter.Antialiasing, True) c = self.d / 2.0 r = c - 5 if self.state == "recording": halo = 4 + 3 * (0.5 + 0.5 * math.sin(self._pulse * 2 * math.pi)) p.setPen(Qt.NoPen) p.setBrush(QColor(255, 71, 87, 60)) p.drawEllipse(QPointF(c, c), r + halo, r + halo) base = {"idle": QColor("#242A36"), "recording": QColor(REC), "paused": QColor(WARN)}[self.state] p.setPen(QPen(QColor("#0B0E13"), 2)) p.setBrush(base) p.drawEllipse(QPointF(c, c), r, r) inner = {"idle": QColor(REC), "recording": QColor("#FFFFFF"), "paused": QColor("#1A1E27")}[self.state] p.setPen(Qt.NoPen) p.setBrush(inner) if self.state == "recording": p.drawRoundedRect(QRectF(c - 11, c - 11, 22, 22), 3, 3) elif self.state == "paused": p.drawRoundedRect(QRectF(c - 11, c - 11, 8, 22), 2, 2) p.drawRoundedRect(QRectF(c + 3, c - 11, 8, 22), 2, 2) else: p.drawEllipse(QPointF(c, c), r * 0.52, r * 0.52) p.end() class StatusPill(QWidget): """小圆角状态标签(正常/警告/错误)。""" def __init__(self, text: str = "", level: str = "ok", parent=None): super().__init__(parent) self.text = text self.level = level self.setMinimumHeight(22) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) def set(self, text: str, level: str = "ok") -> None: if text == self.text and level == self.level: return self.text = text self.level = level self.update() def paintEvent(self, _event) -> None: # noqa: N802 p = QPainter(self) p.setRenderHint(QPainter.Antialiasing, True) color = {"ok": OK, "warn": WARN, "err": CRIT, "info": ACCENT, "dim": TEXT_DIM}.get(self.level, TEXT_DIM) p.setPen(Qt.NoPen) p.setBrush(QColor(color)) p.setOpacity(0.16) p.drawRoundedRect(QRectF(0, 1, self.width(), self.height() - 2), 5, 5) p.setOpacity(1.0) p.setPen(QColor(color)) p.drawText(self.rect().adjusted(8, 0, -8, 0), Qt.AlignLeft | Qt.AlignVCenter, self.text) p.end() class HistoryPlot(QWidget): """整段录音的峰值包络总览(类似 DAW 的概览条)。""" def __init__(self, parent=None): super().__init__(parent) self.setMinimumHeight(72) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self._mins = np.zeros((0, 2), np.float32) self._maxs = np.zeros((0, 2), np.float32) self._cursor = 0.0 self._buckets_per_second = 48000 / 256 def update_envelope(self, mins: np.ndarray, maxs: np.ndarray, bucket: int, samplerate: int, cursor_seconds: float) -> None: self._mins, self._maxs = mins, maxs self._buckets_per_second = samplerate / max(1, bucket) self._cursor = cursor_seconds self.update() def paintEvent(self, _event) -> None: # noqa: N802 p = QPainter(self) w, h = self.width(), self.height() p.fillRect(0, 0, w, h, QColor(PANEL)) p.setPen(QPen(QColor(BORDER), 1)) p.drawRect(0, 0, w - 1, h - 1) yc = h / 2 p.setPen(QPen(QColor(255, 255, 255, 36), 1)) p.drawLine(QPointF(0, yc), QPointF(w, yc)) if self._mins.size == 0: p.setPen(QColor(TEXT_DIM)) p.drawText(self.rect(), Qt.AlignCenter, "录音总览(开始录音后显示)") p.end() return n = self._mins.shape[0] cols = max(1, min(w, 4000)) per = max(1, n // cols) usable = (n // per) * per mn = self._mins[:usable].reshape(-1, per, self._mins.shape[1]).min(axis=1) mx = self._maxs[:usable].reshape(-1, per, self._maxs.shape[1]).max(axis=1) scale = (h / 2 - 4) p.setPen(QPen(QColor(ACCENT), 1)) for i in range(mn.shape[0]): x = i * (w / max(1, mn.shape[0])) top = yc - float(mx[i, 0]) * scale bot = yc - float(mn[i, 0]) * scale if bot - top < 1: top, bot = top - 0.5, bot + 0.5 p.drawLine(QPointF(x, top), QPointF(x, bot)) # 光标 total_seconds = n / self._buckets_per_second if total_seconds > 0: cx = min(w - 1, self._cursor / total_seconds * w) p.setPen(QPen(QColor(REC), 1)) p.drawLine(QPointF(cx, 0), QPointF(cx, h)) p.end() class MarkerRail(QWidget): """标记轨道:把标记点画在整段录音的时间轴上。""" def __init__(self, parent=None): super().__init__(parent) self.setFixedHeight(18) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self._marks: list[tuple[float, str]] = [] self._total = 0.0 def set_marks(self, marks: list[tuple[float, str]], total: float) -> None: self._marks = marks self._total = max(1e-6, total) self.update() def paintEvent(self, _event) -> None: # noqa: N802 p = QPainter(self) w, h = self.width(), self.height() p.fillRect(0, 0, w, h, QColor(PANEL)) p.setPen(QPen(QColor(BORDER), 1)) p.drawLine(0, h - 1, w, h - 1) p.setPen(QPen(QColor("#FFD24A"), 1)) for sec, _label in self._marks: x = min(w - 1.0, max(0.0, sec / self._total * w)) p.drawLine(QPointF(x, 2), QPointF(x, h - 2)) if not self._marks: p.setPen(QColor(TEXT_DIM)) p.drawText(self.rect(), Qt.AlignLeft | Qt.AlignVCenter, " 标记轨道(录音中按 M 打点)") p.end()