You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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这类专业音频编辑器的流畅波形显示核心在于多分辨率预计算缓存和按需渲染:

  1. 多分辨率缓存:提前对音频数据进行不同层级的降采样处理,生成从全精度到低精度的多个版本。比如最高精度对应原始采样,下一层是每N个采样取最大/最小值,再下一层是每M个采样取最大/最小值(M>N)。缩放时直接调用对应精度的缓存数据,无需实时计算。
  2. 按需渲染:只渲染当前视图可见区域的波形,而非整个音频文件。配合滚动、缩放操作,仅更新可见范围内的内容,避免不必要的计算和绘制。
  3. 离屏绘制:使用离屏位图(如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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.13 00:40:39