Building an Interactive Audio Editor with PyQtGraph and PyQt6

Use PyQtGraph's fast, interactive plotting to visualize audio waveforms and spectra in a PyQt6 application
Heads up! You've already completed this tutorial.

I want to build an audio editor for sound quality engineering in Python. Is PyQtGraph the best choice for plotting audio waveforms and spectra? I'm looking to display left and right channel time series, a running spectrum, and some DSP filtering — all interactively.

If you're building an audio editor or visualization tool in Python, PyQtGraph is an excellent choice for the plotting side of things. It's built directly on top of Qt's graphics framework, which gives you two big advantages over alternatives like Matplotlib:

  1. Speed — PyQtGraph uses Qt's native drawing system, so it can handle large datasets (like audio waveforms) smoothly and efficiently.
  2. Interactivity — Zooming, panning, and selecting regions feel natural and responsive out of the box, which is exactly what you need for an audio editor where users will be scrubbing through waveforms constantly.

In this tutorial, we'll walk through building a basic interactive audio editor layout using PyQt6 and PyQtGraph. By the end, you'll have a working application that loads a WAV file, displays the left and right audio channels, shows a frequency spectrum, and lets you zoom and pan through the data interactively.

Setting up your environment

You'll need a few packages installed. You can install them all with pip:

sh
$ pip install PyQt6 pyqtgraph numpy scipy

We'll use scipy.io.wavfile to load WAV files and numpy for computing the frequency spectrum.

Creating the basic window layout

Let's start with a simple PyQt6 window that has the layout we want: a spectrum plot on top, and two waveform plots (left and right channels) stacked below it. PyQtGraph makes this straightforward because its PlotWidget is a regular Qt widget — you can drop it into any layout:

python
import sys
import numpy as np
from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
import pyqtgraph as pg

class AudioEditorWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Audio Editor")
        self.resize(1000, 700)

        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        layout = QVBoxLayout(central_widget)

        # Spectrum plot (top)
        self.spectrum_plot = pg.PlotWidget(title="Frequency Spectrum")
        self.spectrum_plot.setLabel("bottom", "Frequency", units="Hz")
        self.spectrum_plot.setLabel("left", "Magnitude")
        layout.addWidget(self.spectrum_plot)

        # Left channel waveform
        self.left_plot = pg.PlotWidget(title="Left Channel")
        self.left_plot.setLabel("bottom", "Time", units="s")
        self.left_plot.setLabel("left", "Amplitude")
        layout.addWidget(self.left_plot)

        # Right channel waveform
        self.right_plot = pg.PlotWidget(title="Right Channel")
        self.right_plot.setLabel("bottom", "Time", units="s")
        self.right_plot.setLabel("left", "Amplitude")
        layout.addWidget(self.right_plot)

app = QApplication(sys.argv)
window = AudioEditorWindow()
window.show()
app.exec()

Run this and you'll see three empty plot areas stacked vertically. The top one is for the spectrum, and the bottom two are for the left and right audio channels. Each plot already supports mouse-wheel zooming and click-and-drag panning — that's PyQtGraph's default behavior.

Loading and displaying audio data

Now let's load a WAV file and plot the waveforms. We'll use scipy.io.wavfile to read the file, then create a time axis based on the sample rate:

python
from scipy.io import wavfile

def load_audio(filepath):
    sample_rate, data = wavfile.read(filepath)

    # If mono, duplicate to two channels for consistency
    if data.ndim == 1:
        data = np.column_stack([data, data])

    # Normalize to -1.0 to 1.0 range
    max_val = np.iinfo(data.dtype).max if np.issubdtype(data.dtype, np.integer) else 1.0
    data = data.astype(np.float64) / max_val

    time_axis = np.arange(data.shape[0]) / sample_rate
    return sample_rate, time_axis, data[:, 0], data[:, 1]

This function returns the sample rate, a time axis in seconds, and the left and right channel data normalized between -1 and 1. Normalizing makes the waveform display consistent regardless of the bit depth of the original file.

Linking the X axes

In an audio editor, when you zoom or pan on one waveform, you almost always want the other waveform to follow along. PyQtGraph makes this simple with setXLink:

Create GUI Applications with Python & Qt6 by Martin Fitzpatrick — (PyQt6 Edition) The hands-on guide to making apps with Python — Save time and build better with this book. Over 15K copies sold.

Get the book

python
self.right_plot.setXLink(self.left_plot)

After adding this line, zooming or panning on either the left or right channel plot will keep them synchronized along the time axis.

Adding a frequency spectrum

To display a frequency spectrum, we'll compute the FFT (Fast Fourier Transform) of a section of the audio. For a full audio editor you'd want this to update as the user scrolls through the waveform, but let's start by computing the spectrum of the entire file (or a chunk of it):

python
def compute_spectrum(signal, sample_rate, max_freq=20000):
    n = len(signal)
    fft_data = np.fft.rfft(signal)
    magnitude = np.abs(fft_data) / n
    frequencies = np.fft.rfftfreq(n, d=1.0 / sample_rate)

    # Limit to max_freq (audible range)
    mask = frequencies <= max_freq
    return frequencies[mask], magnitude[mask]

We use rfft (the "real" FFT) because audio data is real-valued, which is more efficient and gives us only the positive frequencies. The result is limited to 20 kHz — the upper bound of human hearing — so the plot focuses on the useful range.

Adding a region selector

One of PyQtGraph's strengths is how easy it is to add interactive elements. A LinearRegionItem lets the user select a region of the waveform by dragging, which is perfect for selecting a section of audio to analyze or edit:

python
self.region = pg.LinearRegionItem()
self.left_plot.addItem(self.region)

You can then connect to the region's sigRegionChanged signal to update the spectrum whenever the user moves the selection:

python
self.region.sigRegionChanged.connect(self.update_spectrum)

Putting it all together

Here's a complete working example that combines everything above. It loads a WAV file, displays both channels, shows the frequency spectrum of a selectable region, and keeps everything interactive and synchronized.

To try it out, update the audio_file path to point to a stereo WAV file on your system:

python
import sys
import numpy as np
from scipy.io import wavfile
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QVBoxLayout, QWidget,
    QFileDialog, QToolBar
)
from PyQt6.QtGui import QAction
import pyqtgraph as pg

def load_audio(filepath):
    """Load a WAV file and return normalized channel data."""
    sample_rate, data = wavfile.read(filepath)

    # If mono, duplicate to two channels
    if data.ndim == 1:
        data = np.column_stack([data, data])

    # Normalize to float in range -1.0 to 1.0
    if np.issubdtype(data.dtype, np.integer):
        max_val = np.iinfo(data.dtype).max
    else:
        max_val = 1.0
    data = data.astype(np.float64) / max_val

    time_axis = np.arange(data.shape[0]) / sample_rate
    return sample_rate, time_axis, data[:, 0], data[:, 1]

def compute_spectrum(signal, sample_rate, max_freq=20000):
    """Compute the frequency spectrum of a signal."""
    n = len(signal)
    if n == 0:
        return np.array([]), np.array([])

    fft_data = np.fft.rfft(signal)
    magnitude = np.abs(fft_data) / n
    frequencies = np.fft.rfftfreq(n, d=1.0 / sample_rate)

    mask = frequencies <= max_freq
    return frequencies[mask], magnitude[mask]

class AudioEditorWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Audio Editor")
        self.resize(1100, 750)

        self.sample_rate = None
        self.time_axis = None
        self.left_data = None
        self.right_data = None

        self.setup_ui()
        self.setup_toolbar()

    def setup_ui(self):
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
        layout = QVBoxLayout(central_widget)

        # Use a dark background for a professional audio editor look
        pg.setConfigOption("background", "k")
        pg.setConfigOption("foreground", "w")

        # Spectrum plot (top)
        self.spectrum_plot = pg.PlotWidget(title="Frequency Spectrum")
        self.spectrum_plot.setLabel("bottom", "Frequency", units="Hz")
        self.spectrum_plot.setLabel("left", "Magnitude")
        self.spectrum_plot.showGrid(x=True, y=True, alpha=0.3)
        layout.addWidget(self.spectrum_plot)

        # Left channel waveform
        self.left_plot = pg.PlotWidget(title="Left Channel")
        self.left_plot.setLabel("bottom", "Time", units="s")
        self.left_plot.setLabel("left", "Amplitude")
        self.left_plot.showGrid(x=True, y=True, alpha=0.3)
        layout.addWidget(self.left_plot)

        # Right channel waveform
        self.right_plot = pg.PlotWidget(title="Right Channel")
        self.right_plot.setLabel("bottom", "Time", units="s")
        self.right_plot.setLabel("left", "Amplitude")
        self.right_plot.showGrid(x=True, y=True, alpha=0.3)
        layout.addWidget(self.right_plot)

        # Link the X axes of left and right channels
        self.right_plot.setXLink(self.left_plot)

        # Region selector on the left channel plot
        self.region = pg.LinearRegionItem()
        self.region.setZValue(10)  # Draw on top of the waveform
        self.left_plot.addItem(self.region)
        self.region.sigRegionChanged.connect(self.update_spectrum)

    def setup_toolbar(self):
        toolbar = QToolBar("Main Toolbar")
        self.addToolBar(toolbar)

        open_action = QAction("Open WAV File", self)
        open_action.triggered.connect(self.open_file)
        toolbar.addAction(open_action)

    def open_file(self):
        filepath, _ = QFileDialog.getOpenFileName(
            self, "Open WAV File", "", "WAV Files (*.wav)"
        )
        if filepath:
            self.load_and_display(filepath)

    def load_and_display(self, filepath):
        self.sample_rate, self.time_axis, self.left_data, self.right_data = (
            load_audio(filepath)
        )

        # Downsample for display if the file is very large.
        # PyQtGraph handles large datasets well, but downsampling
        # speeds up the initial render for very long files.
        display_left = self.left_data
        display_right = self.right_data
        display_time = self.time_axis

        max_display_points = 500_000
        if len(self.time_axis) > max_display_points:
            step = len(self.time_axis) // max_display_points
            display_time = self.time_axis[::step]
            display_left = self.left_data[::step]
            display_right = self.right_data[::step]

        # Plot waveforms
        self.left_plot.clear()
        self.left_plot.plot(display_time, display_left, pen=pg.mkPen("c", width=1))
        self.left_plot.addItem(self.region)

        self.right_plot.clear()
        self.right_plot.plot(display_time, display_right, pen=pg.mkPen("m", width=1))

        # Set region to the first 10% of the file (or the whole thing if short)
        duration = self.time_axis[-1]
        region_end = min(duration, duration * 0.1)
        self.region.setRegion([0, region_end])

        # Initial spectrum update
        self.update_spectrum()

    def update_spectrum(self):
        if self.left_data is None:
            return

        # Get the selected region bounds
        min_time, max_time = self.region.getRegion()

        # Convert time bounds to sample indices
        start_idx = max(0, int(min_time * self.sample_rate))
        end_idx = min(len(self.left_data), int(max_time * self.sample_rate))

        if end_idx <= start_idx:
            return

        # Use the left channel for the spectrum (you could average both)
        segment = self.left_data[start_idx:end_idx]
        frequencies, magnitude = compute_spectrum(segment, self.sample_rate)

        # Convert magnitude to dB for a more useful display
        magnitude_db = 20 * np.log10(magnitude + 1e-10)

        self.spectrum_plot.clear()
        self.spectrum_plot.plot(frequencies, magnitude_db, pen=pg.mkPen("y", width=1))

def main():
    app = QApplication(sys.argv)
    window = AudioEditorWindow()
    window.show()
    sys.exit(app.exec())

if __name__ == "__main__":
    main()

When you run this, click Open WAV File in the toolbar to load an audio file. You'll see:

  • The left and right channel waveforms plotted in cyan and magenta.
  • A draggable region selector (the shaded band) on the left channel plot.
  • A frequency spectrum (in dB) that updates automatically as you drag the region.
  • Full zoom and pan support on all three plots — use the mouse wheel to zoom and click-drag to pan.

Audio editor screenshot

Where to go from here

This gives you a solid foundation for an audio editor interface. Here are some directions you could explore next:

  • Spectrogram display — PyQtGraph's ImageItem can render a spectrogram (time vs. frequency heatmap) very efficiently. You'd compute a Short-Time Fourier Transform (STFT) using scipy.signal.stft and display the result as an image.
  • Playback cursor — Add an InfiniteLine item that moves across the waveform during audio playback. You can use a QTimer to update its position.
  • DSP filtering — Apply filters using scipy.signal (e.g., bandpass, notch filters) and display the filtered waveform alongside the original.
  • Custom annotations — Because PyQtGraph widgets are standard Qt graphics items, you can subclass them to add text labels, markers, or clickable regions for tagging sections of audio.
  • Real-time input — For live audio monitoring, you could feed data from sounddevice or pyaudio into the plots and update them on a timer.

PyQtGraph's combination of performance and deep Qt integration makes it a natural fit for this kind of application. You get the interactive, responsive feel that audio engineering tools require, while staying entirely within the Python ecosystem.

PyQt/PySide Development Services — Stuck in development hell? I'll help you get your project focused, finished and released. Benefit from years of practical experience releasing software with Python.

Find out More

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak
Martin Fitzpatrick

Building an Interactive Audio Editor with PyQtGraph and PyQt6 was written by Martin Fitzpatrick with contributions from Leo Well.

Martin Fitzpatrick is the creator of Python GUIs, and has been developing Python/Qt applications for the past 12+ years. He has written a number of popular Python books and provides Python software development & consulting for teams and startups.