PyQt6音频波形绘制优化:如何实现Audacity级流畅渲染与动态缩放
问题
我正在开发一款用于自定义LED控制器制作灯光秀的应用,需要在Widget上绘制歌曲波形。目前功能已实现,但绘制时长超过几秒的.wav文件时速度极慢,不清楚优化方向及当前方案是否正确。
想了解:
- 音频编辑器(如Audacity)如何实现无延迟的波形显示与缩放?
- 正确的实现思路是什么?
当前使用PyQt6的QGraphicsView和QGraphicsScene绘制,核心逻辑在WavDisplay类的drawWav()方法中,相关代码如下:
Showcreator.py
from PyQt6 import uic from PyQt6.QtCore import ( QSize, Qt ) from PyQt6.QtGui import ( QAction, QPen, QPixmap, QPainter, QColor, QImage ) from PyQt6.QtWidgets import ( QMainWindow, QWidget, QStatusBar, QFileDialog, QGraphicsScene, QGraphicsView, QGridLayout ) import sys import wave import pyaudio import numpy as np import threading import soundfile as sf import threading class MainWindow(QMainWindow): # audio chunk rate CHUNK = 1024 def __init__(self): super().__init__() # set window title self.setWindowTitle("LED Music Show") # create file button button_action = QAction("Open .wav file", self) button_action.setStatusTip("Open a Wave file to the Editor.") button_action.triggered.connect(self.openWav) # set status bar self.setStatusBar(QStatusBar(self)) # create menubar menu = self.menuBar() # add file button to status bar file_menu = menu.addMenu("&File") file_menu.addAction(button_action) # create layout layout = QGridLayout() layout.setContentsMargins(0,0,0,0) # create Wave display object self.waveformspace = WavDisplay() # add widget to layout layout.addWidget(self.waveformspace, 0, 1) self.centralWidget = QWidget() self.centralWidget.setLayout(layout) self.setCentralWidget(self.centralWidget) def openWav(self): # file selection window self.filename, check = QFileDialog.getOpenFileName(self,"QFileDialog.getOpenFileName()", "","Wave files (*.wav)") self.file = None # try to open .wav with two methods try: try: self.file = wave.open(self.filename, "rb") except: print("Failed to open with wave") try: self.file, samplerate = sf.read(self.filename, dtype='float32') except: print("Failed to open with soundfile") # read file and convert it to array self.signal = self.file.readframes(-1) self.signal = np.fromstring(self.signal, dtype = np.int16) # set file for drawing self.waveformspace.setWavefile(self.signal) self.waveformspace.drawWav() # return file cursor to start self.file.rewind() # start thread for the player # self.player = threading.Thread(target = self.playWav) # try: # self.player.daemon = True # except: # print("Failed to set player to Daemon") # self.player.start() except: print("Err opening File") def playWav(self): lastFile = None lastpos = None p = pyaudio.PyAudio() data = None sampwidth = None fps = None chn = None farmes = None currentpos = 0 framespersec = None while True: if self.file != lastFile: # get file info sampwidth = self.file.getsampwidth() fps = self.file.getframerate() chn = self.file.getnchannels() frames = self.file.getnframes() lastFile = self.file # open audio stream stream = p.open(format = p.get_format_from_width(sampwidth), channels = chn, rate = fps, output = True) # read first frame data = self.file.readframes(self.CHUNK) framespersec = sampwidth * chn * fps print("file changed") if self.pos != lastpos: # read file for offset self.file.readframes(int(self.pos * framespersec)) lastpos = self.pos frames = self.file.getnframes() print("pos changed") while data and self.running: # writing to the stream stream.write(data) data = self.file.readframes(self.CHUNK) currentpos = currentpos + self.CHUNK # cleanup stuff. self.file.close() stream.close() p.terminate() return class WavDisplay(QGraphicsView): file = None maxAmplitude = 0 fileset = False def __init__(self): super().__init__() def setWavefile(self, externFile): self.file = externFile self.fileset = True # find the max deviation from 0 db to set draw borders if max(self.file) > abs(min(self.file)): self.maxAmplitude = max(self.file) * 2 else: self.maxAmplitude = abs(min(self.file)) * 2 def drawWav(self): # only draw when there is a set file if self.fileset: width = self.frameGeometry().width() height = self.frameGeometry().height() vStep = height / self.maxAmplitude scene = QGraphicsScene(self) # to draw on the middle of the widget h = height / 2 # method 1 of drawing: looks at sections of the file and determines the max and min amplitude that would be visible on a single "column" of pixels and draws a vertical line between them if width < len(self.file): hStep = len(self.file) / width drawArray = np.empty((width, 3)) for i in range(width - 1): buffer = self.file[int(np.ceil(i * hStep)) : int(np.ceil((i + 1) * hStep))] drawArray[i][0] = (min(buffer) * vStep) + h drawArray[i][1] = (max(buffer) * vStep) + h for i in range(width - 1): self.line = scene.addLine(i, drawArray[i][0], i, drawArray[i][1]) # method 2 of drawing: this only happens when the amount of samples to draw is less than the windows width (e.g. when zoomed in and you can see the individual samples) else: hStep = width / len(self.file) for i in range(len(self.file) - 1): self.line = scene.addLine(i * hStep, int(self.file[i] * vStep + h), (i + 1) * hStep, int(self.file[i + 1] * vStep + h)) self.setScene(scene) self.setContentsMargins(0,0,0,0) self.show() def resizeEvent(self, event) -> None: # has to redraw the wave file if the window gets resized self.drawWav() # class not used yet class effectList(QGraphicsView): bpm = 130 trackBeats = 0 def __init__(self): super().__init__() def setBeatsAndBpm(self, trackLenght, Bpm): self.bpm = Bpm self.trackBeats = (trackLenght / 60) * self.bpm
main.py
from PyQt6 import QtCore, QtGui, QtWidgets from Showcreator import MainWindow app = QtWidgets.QApplication([]) window = MainWindow() window.show() app.exec()
我已采用仅绘制与窗口宽度等量线条的算法(而非所有采样点),推测问题出在渲染方式上。目前已将音频数据预加载为numpy数组,需实现整体显示与动态缩放的流畅波形视图。
解答
Audacity的实现思路
Audacity这类专业音频编辑器的流畅波形显示核心在于多分辨率预计算缓存和按需渲染:
- 多分辨率缓存:提前对音频数据进行不同层级的降采样处理,生成从全精度到低精度的多个版本。比如最高精度对应原始采样,下一层是每N个采样取最大/最小值,再下一层是每M个采样取最大/最小值(M>N)。缩放时直接调用对应精度的缓存数据,无需实时计算。
- 按需渲染:只渲染当前视图可见区域的波形,而非整个音频文件。配合滚动、缩放操作,仅更新可见范围内的内容,避免不必要的计算和绘制。
- 离屏绘制:使用离屏位图(如QImage)预先绘制波形,再将位图渲染到界面,减少QGraphicsScene中大量图元带来的性能开销。
当前代码的优化方向
1. 替换QGraphicsScene的大量图元绘制
当前代码每次绘制都创建数百/数千个QGraphicsLineItem,每个图元都有独立的渲染开销,这是性能瓶颈的核心。改为直接用QPainter在QImage上绘制,再将QImage作为单个图元添加到场景:
def drawWav(self): if self.fileset: width = self.frameGeometry().width() height = self.frameGeometry().height() vStep = height / self.maxAmplitude h = height / 2 # 创建离屏图像 img = QImage(width, height, QImage.Format_RGB32) img.fill(Qt.GlobalColor.white) painter = QPainter(img) pen = QPen(Qt.GlobalColor.black, 1) painter.setPen(pen) if width < len(self.file): hStep = len(self.file) / width for i in range(width - 1): buffer = self.file[int(np.ceil(i * hStep)) : int(np.ceil((i + 1) * hStep))] min_y = (min(buffer) * vStep) + h max_y = (max(buffer) * vStep) + h painter.drawLine(i, int(min_y), i, int(max_y)) else: hStep = width / len(self.file) for i in range(len(self.file) - 1): x1 = i * hStep y1 = int(self.file[i] * vStep + h) x2 = (i + 1) * hStep y2 = int(self.file[i + 1] * vStep + h) painter.drawLine(int(x1), y1, int(x2), y2) painter.end() # 用QGraphicsPixmapItem显示图像 scene = QGraphicsScene(self) scene.addPixmap(QPixmap.fromImage(img)) self.setScene(scene)
2. 预计算多分辨率缓存
提前为音频数据生成不同缩放级别的缓存,避免每次缩放/resize时重新计算所有片段的最大最小值:
class WavDisplay(QGraphicsView): file = None maxAmplitude = 0 fileset = False # 存储不同层级的缓存:键是缩放比例对应的采样间隔,值是(max, min)数组 resolution_cache = {} def setWavefile(self, externFile): self.file = externFile self.fileset = True # 计算最大振幅 self.maxAmplitude = max(max(externFile), abs(min(externFile))) * 2 # 预计算常用分辨率的缓存 self.resolution_cache = {} current_interval = 1 while current_interval <= len(self.file): # 按间隔分块计算max和min chunks = np.array_split(self.file, len(self.file) // current_interval) cache = [] for chunk in chunks: cache.append((np.max(chunk), np.min(chunk))) self.resolution_cache[current_interval] = cache current_interval *= 2
绘制时根据当前窗口宽度选择最接近的缓存间隔,直接读取预计算的max/min值,无需实时计算片段极值。
3. 优化resizeEvent的触发逻辑
当前resizeEvent每次都会调用drawWav,可以判断窗口尺寸变化是否超过一定阈值再重新绘制,避免频繁触发:
def resizeEvent(self, event) -> None: old_size = event.oldSize() new_size = event.size() # 只有当尺寸变化超过10像素时才重新绘制 if abs(old_size.width() - new_size.width()) > 10 or abs(old_size.height() - new_size.height()) > 10: self.drawWav()
4. 使用numpy加速计算
用numpy的向量化操作替代Python循环计算片段max/min,大幅提升计算速度:
if width < len(self.file): hStep = len(self.file) / width # 生成分割点 split_points = np.ceil(np.arange(0, len(self.file), hStep)).astype(int) # 用numpy分块计算max和min max_vals = np.array([np.max(self.file[split_points[i]:split_points[i+1]]) for i in range(len(split_points)-1)]) min_vals = np.array([np.min(self.file[split_points[i]:split_points[i+1]]) for i in range(len(split_points)-1)]) # 转换为坐标 min_ys = (min_vals * vStep) + h max_ys = (max_vals * vStep) + h # 批量绘制线条 for i in range(len(max_vals)): painter.drawLine(i, int(min_ys[i]), i, int(max_ys[i]))
内容的提问来源于stack exchange,提问作者NoWayOut8344
相关产品推荐
相关产品推荐

