What is a good way to draw a waveform with pyqt6?

157 Views Asked by At

Currently making an application which allows me to make a lightshow with some custom build LED-Controllers and for that i need to draw the waveform of the song on a widget.

Although I managed to do this it is still VERY slow (especially with .wav files longer than a few seconds). The thing is I don't know how to optimise this or if my approach is correct since i cant find anything on the web.

So my question is: what is the right way to go about this? How do audio editors display the waveform and are able to zoom in and out without lag?

So my current attempt at this is by using QGraphicsView and a QGraphicsScene, the latter one supposedly being made to represent a lot of custom graphics items.

The main function to look at here is drawWav() in class WavDisplay

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()

In essence: Where do i need to start to make this wave file view like one in for example Audacity? (Aka a fast rendering view which doesnt take ages)

Btw i have looked at seemingly duplicates of this question and as you can see in the code i have an algorythem that is only drawing as many lines as the window is wide and not all the 100000+ lines for each sample so the main problem i have should be the rendering method i guess.

Edit: I have all the data preloaded as im loading a wave file and convert it into a numpy array. And i need to display the file as a whole but be able to zoom in dynamically-

0

There are 0 best solutions below