565 lines
21 KiB
Python
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()
|