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.
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:
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.
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.
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:
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:
- Read the current playback position from
QMediaPlayer(in milliseconds). - Slice a 50ms chunk from the pydub
AudioSegmentat that position. - Read the
.dBFSproperty of the chunk. - 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:
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
AudioSegmentinto left and right channels usingchunk.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.
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.