Adding a Volume Meter to a PyQt5 Media Player

Use pydub and QMediaPlayer together to display real-time audio levels
Heads up! You've already completed this tutorial.

If you've built a media player with PyQt5's QMediaPlayer (like the one in our Failamp tutorial), you might want to add a visual volume meter that shows the loudness of the audio as it plays. A bouncing dB meter is a satisfying feature, and it gives your player a more professional feel.

There's a catch, though: QMediaPlayer doesn't give you direct access to the raw audio samples as they play. It handles decoding and output internally, so there's no built-in signal you can tap into for a live PCM stream. This leaves us needing a workaround.

The approach we'll use is straightforward: open the same audio file separately using pydub, and use the playback position reported by QMediaPlayer to extract the corresponding chunk of audio. From that chunk, we can read the loudness in decibels and display it in a custom widget.

This technique is inspired by the MilkPlayer project, which uses a similar strategy to drive a full graphic equalizer.

Why not just read the stream from QMediaPlayer?

You might have seen references to QMediaPlayer providing access to a "stream" of audio data. This is a bit misleading. QMediaPlayer can play from a QIODevice stream (for example, streaming audio from a network source), but it doesn't expose the decoded audio output as a readable stream. The audio data goes straight to the platform's audio backend.

Qt does have lower-level classes like QAudioProbe (available in Qt 5) that were designed for exactly this purpose — intercepting audio buffers during playback. However, QAudioProbe support is platform-dependent and was removed entirely in Qt 6. So relying on it isn't a great long-term strategy.

Using pydub to independently read the file is reliable, cross-platform, and gives us full control over the analysis.

Installing dependencies

You'll need pydub installed. It uses FFmpeg under the hood for decoding, so make sure you have that available on your system too.

bash
pip install pydub

On most systems, you can install FFmpeg via your package manager (e.g., brew install ffmpeg on macOS, sudo apt install ffmpeg on Ubuntu), or download it from ffmpeg.org.

Loading audio with pydub

The AudioSegment class in pydub can open audio files in many formats. Once loaded, you can slice the audio by millisecond position — which maps perfectly to the millisecond positions reported by QMediaPlayer.

Here's a quick example of how that works:

python
from pydub import AudioSegment

audio = AudioSegment.from_file("my_song.mp3")

# Get a 50ms chunk starting at 3000ms (3 seconds in)
chunk = audio[3000:3050]

# Get the loudness in dBFS (decibels relative to full scale)
print(chunk.dBFS)

The .dBFS property returns the loudness of that audio segment as a negative number. 0 dBFS is the maximum possible level, and silence approaches negative infinity (pydub returns -float('inf') for completely silent chunks).

Building the volume meter widget

Let's create a custom widget that draws a simple horizontal bar representing the current audio level. We'll map the dBFS value to a visual range.

python
from PyQt5.QtWidgets import QWidget
from PyQt5.QtGui import QPainter, QColor
from PyQt5.QtCore import Qt


class VolumeMeter(QWidget):
    """A simple horizontal volume meter widget."""

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setMinimumHeight(20)
        self.setMaximumHeight(30)
        self._level = 0.0  # 0.0 (silent) to 1.0 (max)

    def set_level(self, level):
        """Set the current level as a float between 0.0 and 1.0."""
        self._level = max(0.0, min(1.0, level))
        self.update()

    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing)

        # Background
        painter.setBrush(QColor("#2e2e2e"))
        painter.setPen(Qt.NoPen)
        painter.drawRect(self.rect())

        # Level bar
        bar_width = int(self.width() * self._level)
        if self._level > 0.8:
            color = QColor("#e74c3c")  # Red for loud
        elif self._level > 0.5:
            color = QColor("#f39c12")  # Orange for medium
        else:
            color = QColor("#2ecc71")  # Green for quiet

        painter.setBrush(color)
        painter.drawRect(0, 0, bar_width, self.height())

        painter.end()

This widget takes a level between 0.0 and 1.0, and draws a colored bar. Green for quiet, orange for medium, and red when things get loud.

Converting dBFS to a display level

We need a function that converts the dBFS value into a 0.0–1.0 range suitable for our meter. Audio loudness is logarithmic, and dBFS values typically range from about -60 dB (very quiet) to 0 dB (maximum). We'll clamp the range and scale linearly within it.

python
def dbfs_to_level(dbfs, min_db=-60.0, max_db=0.0):
    """Convert a dBFS value to a 0.0-1.0 level for display."""
    if dbfs == float('-inf'):
        return 0.0
    clamped = max(min_db, min(max_db, dbfs))
    return (clamped - min_db) / (max_db - min_db)

A dBFS of -60 maps to 0.0, and 0 dBFS maps to 1.0. Anything below -60 is treated as silence.

Tying it together with QMediaPlayer

Now we need to periodically check the current playback position from QMediaPlayer, extract the corresponding audio chunk with pydub, calculate the dBFS, and update the meter.

A QTimer is perfect for this. We'll poll the player position every 50 milliseconds and analyze a 50ms chunk of audio at that position.

Here's a complete working example that puts everything together into a minimal media player with a live volume meter:

python
import sys

from PyQt5.QtWidgets import (
    QApplication,
    QMainWindow,
    QWidget,
    QVBoxLayout,
    QPushButton,
    QFileDialog,
    QLabel,
)
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent
from PyQt5.QtGui import QPainter, QColor
from PyQt5.QtCore import Qt, QUrl, QTimer

from pydub import AudioSegment


class VolumeMeter(QWidget):
    """A simple horizontal volume meter widget."""

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setMinimumHeight(20)
        self.setMaximumHeight(30)
        self._level = 0.0

    def set_level(self, level):
        self._level = max(0.0, min(1.0, level))
        self.update()

    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing)

        # Background
        painter.setBrush(QColor("#2e2e2e"))
        painter.setPen(Qt.NoPen)
        painter.drawRect(self.rect())

        # Level bar
        bar_width = int(self.width() * self._level)
        if self._level > 0.8:
            color = QColor("#e74c3c")
        elif self._level > 0.5:
            color = QColor("#f39c12")
        else:
            color = QColor("#2ecc71")

        painter.setBrush(color)
        painter.drawRect(0, 0, bar_width, self.height())

        painter.end()


def dbfs_to_level(dbfs, min_db=-60.0, max_db=0.0):
    """Convert a dBFS value to a 0.0-1.0 level."""
    if dbfs == float("-inf"):
        return 0.0
    clamped = max(min_db, min(max_db, dbfs))
    return (clamped - min_db) / (max_db - min_db)


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Media Player with Volume Meter")
        self.setMinimumSize(400, 150)

        # Media player
        self.player = QMediaPlayer()

        # Audio data (loaded separately via pydub)
        self.audio_data = None

        # UI
        layout = QVBoxLayout()

        self.open_button = QPushButton("Open File")
        self.open_button.clicked.connect(self.open_file)
        layout.addWidget(self.open_button)

        self.play_button = QPushButton("Play")
        self.play_button.clicked.connect(self.toggle_playback)
        self.play_button.setEnabled(False)
        layout.addWidget(self.play_button)

        self.status_label = QLabel("No file loaded")
        layout.addWidget(self.status_label)

        self.meter = VolumeMeter()
        layout.addWidget(self.meter)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)

        # Timer to poll playback position and update meter
        self.timer = QTimer()
        self.timer.setInterval(50)  # 50ms refresh rate
        self.timer.timeout.connect(self.update_meter)

        # Update button text when player state changes
        self.player.stateChanged.connect(self.on_state_changed)

    def open_file(self):
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "Open Audio File",
            "",
            "Audio Files (*.mp3 *.wav *.ogg *.flac *.m4a);;All Files (*)",
        )
        if file_path:
            # Load into QMediaPlayer for playback
            url = QUrl.fromLocalFile(file_path)
            self.player.setMedia(QMediaContent(url))

            # Load into pydub for analysis
            try:
                self.audio_data = AudioSegment.from_file(file_path)
                self.status_label.setText(f"Loaded: {file_path.split('/')[-1]}")
                self.play_button.setEnabled(True)
            except Exception as e:
                self.status_label.setText(f"Error loading file: {e}")
                self.audio_data = None
                self.play_button.setEnabled(False)

    def toggle_playback(self):
        if self.player.state() == QMediaPlayer.PlayingState:
            self.player.pause()
        else:
            self.player.play()

    def on_state_changed(self, state):
        if state == QMediaPlayer.PlayingState:
            self.play_button.setText("Pause")
            self.timer.start()
        elif state == QMediaPlayer.PausedState:
            self.play_button.setText("Play")
            self.timer.stop()
        else:
            self.play_button.setText("Play")
            self.timer.stop()
            self.meter.set_level(0.0)

    def update_meter(self):
        if self.audio_data is None:
            return

        position_ms = self.player.position()
        chunk_size = 50  # Analyze 50ms of audio

        # Make sure we don't go past the end
        audio_length = len(self.audio_data)
        if position_ms >= audio_length:
            self.meter.set_level(0.0)
            return

        end_ms = min(position_ms + chunk_size, audio_length)
        chunk = self.audio_data[position_ms:end_ms]

        if len(chunk) == 0:
            self.meter.set_level(0.0)
            return

        level = dbfs_to_level(chunk.dBFS)
        self.meter.set_level(level)


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

Run this, click Open File, choose an audio file, and hit Play. You'll see the green/orange/red bar bouncing along with the music.

How it works

The player uses two separate representations of the same file:

  • QMediaPlayer handles the actual audio playback — decoding, buffering, and sending audio to your speakers.
  • pydub's AudioSegment holds the entire file in memory as raw audio data, ready for analysis.

Every 50 milliseconds, the QTimer fires and we:

  1. Read the current playback position from QMediaPlayer (in milliseconds).
  2. Slice a 50ms chunk from the pydub AudioSegment at that position.
  3. Read the .dBFS property of the chunk.
  4. Convert it to a 0–1 range and update the meter widget.

Because pydub loads the whole file into memory, the slicing and dBFS calculation are very fast — there's no disk I/O during playback.

Smoothing the meter

If the meter feels too jumpy, you can add a simple smoothing factor. Instead of setting the level directly, blend it with the previous value:

python
def update_meter(self):
    if self.audio_data is None:
        return

    position_ms = self.player.position()
    chunk_size = 50
    audio_length = len(self.audio_data)

    if position_ms >= audio_length:
        self.meter.set_level(0.0)
        return

    end_ms = min(position_ms + chunk_size, audio_length)
    chunk = self.audio_data[position_ms:end_ms]

    if len(chunk) == 0:
        self.meter.set_level(0.0)
        return

    target = dbfs_to_level(chunk.dBFS)
    # Smooth: move 40% toward the target each tick
    smoothed = self.meter._level + 0.4 * (target - self.meter._level)
    self.meter.set_level(smoothed)

This makes the bar glide rather than snap between values, which looks more natural — especially for music with a lot of dynamic range.

Going further

This same approach opens the door to more advanced visualizations:

  • Stereo meters: Split the AudioSegment into left and right channels using chunk.split_to_mono() and display separate bars for each.
  • Frequency spectrum: Use NumPy and an FFT on the raw samples (chunk.get_array_of_samples()) to build a spectrum analyzer or equalizer display.
  • Peak hold: Track the maximum level over a short window and draw a small indicator line that slowly decays — a common feature on professional audio meters.

The pattern stays the same in each case: use QMediaPlayer for playback, pydub (and optionally NumPy) for analysis, and QTimer to keep them in sync.

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

Packaging Python Applications with PyInstaller by Martin Fitzpatrick

This step-by-step guide walks you through packaging your own Python applications from simple examples to complete installers and signed executables.

More info Get the book

Martin Fitzpatrick

Adding a Volume Meter to a PyQt5 Media Player was written by Martin Fitzpatrick.

Martin Fitzpatrick has been developing Python/Qt apps for 8 years. Building desktop applications to make data-analysis tools more user-friendly, Python was the obvious choice. Starting with Tk, later moving to wxWidgets and finally adopting PyQt. Martin founded PythonGUIs to provide easy to follow GUI programming tutorials to the Python community. He has written a number of popular Python books on the subject.