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:
- Speed — PyQtGraph uses Qt's native drawing system, so it can handle large datasets (like audio waveforms) smoothly and efficiently.
- 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:
$ 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:
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:
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.
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):
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:
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:
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:
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.

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
ImageItemcan render a spectrogram (time vs. frequency heatmap) very efficiently. You'd compute a Short-Time Fourier Transform (STFT) usingscipy.signal.stftand display the result as an image. - Playback cursor — Add an
InfiniteLineitem that moves across the waveform during audio playback. You can use aQTimerto 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
sounddeviceorpyaudiointo 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.