Files
recorder-studio/recorder/widgets.py
T

565 lines
21 KiB
Python

"""自绘界面控件:专业电平表、实时示波器、响度条、录音按钮、状态标签。
全部用 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()