Clayton BorgesDESENVOLVEDOR
← ← All Projects

SPECtations – Audio Visualizer

2025
Native macOS Desktop App / Real-Time Audio Analysis

macOS desktop app for real-time system audio visualization via BlackHole loopback. Dual FFT pipeline: np.fft.rfft at 15fps for live waveform; scipy.signal.spectrogram at n_fft=2048 for a scrolling 5-second time-frequency heatmap. OpenGL-accelerated via PyQtGraph. Distributable as a .app bundle via PyInstaller.

Overview

SPECtations is a macOS desktop application for real-time visualization of system audio output. It captures all audio playing through the Mac (music, video, any app) using BlackHole, a virtual audio loopback driver, processes it with two independent FFT pipelines, and renders synchronized waveform and spectrogram views. The app was built for continuous background use during music production and media work — designed to sit on a second monitor, always on, with a rich preset system (colormap, sensitivity, persistence effects) and an output window for presentation display.

Problem

macOS does not expose system audio output as a capturable input device (unlike Windows WASAPI loopback). Existing audio visualizers either require routing audio through a DAW or only visualize microphone input. The engineering challenge was both at the OS level (BlackHole workaround) and the signal processing level: a single FFT size produces a tradeoff between time resolution (good for real-time waveform) and frequency resolution (good for spectrogram). Two sizes were needed simultaneously.

Goal

Build a macOS native audio visualizer that: (1) captures system audio output via BlackHole without muting speakers; (2) runs two FFT pipelines with different resolutions simultaneously; (3) renders a scrolling spectrogram heatmap and real-time waveform; (4) supports rich preset configuration (35 colormaps, sensitivity, persistence); (5) distributes as a standalone .app without requiring Python installation.

My Role
  • ◈Solo developer — audio pipeline, signal processing, Qt UI, macOS packaging
Stack
Python 3.10+PySide6 6.5PyQtGraph 0.13sounddeviceNumPySciPyBlackHolePyInstaller
Technical Decisions
  1. 01BlackHole 2ch as the only viable macOS loopback solution — macOS's privacy model prevents direct capture of audio output. BlackHole acts as a virtual output device that simultaneously exposes itself as an input. A Multi-Output Device in Audio MIDI Setup routes audio to both real speakers and BlackHole simultaneously, so the user hears audio normally while SPECtations captures it.
  2. 02Dual FFT sizes: 1024 for waveform, 2048 for spectrogram — the waveform needs low latency (1024 samples ≈ 23ms per chunk at 44.1kHz) with acceptable frequency resolution. The spectrogram needs higher frequency resolution (2048-point FFT gives 21.5Hz per bin) but can be computed at a lower rate. Using scipy.signal.spectrogram with 75% overlap for the historical view vs. np.fft.rfft for the real-time view gives each visualization its optimal parameters.
  3. 03Drop-oldest queue policy for the producer/consumer boundary — PortAudio calls the audio callback on a real-time thread; Qt runs on the main thread. The Queue(maxsize=10) decouples them. When processing lags (e.g., slow spectrogram computation), the queue fills up. Dropping the oldest chunk (rather than blocking the audio thread or growing unbounded) accepts occasional visual glitches over memory exhaustion or audio thread stalls.
  4. 0415fps update rate for readability — an initial 30fps update rate made waveforms move too fast to read in real time. Reducing to 15fps (≈3 audio chunks per GUI frame at 44.1kHz/1024) makes movements readable. The 60fps particle animation timer runs independently so visual overlay effects remain fluid.
  5. 05PySide6 6.5.0 pinned exactly — PyQtGraph had version-specific integration issues with Qt 6.4–6.6. The exact 6.5.0 pin resolved a rendering bug in PyQtGraph's ImageItem update path that caused spectrogram frames to occasionally drop. Version range (>=6.5) was insufficient; exact pinning was required.
  6. 06PyInstaller for .app distribution — the app was intended for use without a Python environment. PyInstaller bundles Python interpreter, all dependencies (PyTorch included), and the app entry point into a self-contained macOS .app. The .spec file handles Apple Silicon ARM64-specific binary inclusions and excludes test files to reduce bundle size.
What I Learned
  • →BlackHole routing requires a Multi-Output Device setup that is non-obvious — the user must create a combined device in Audio MIDI Setup that sends audio to both real speakers and BlackHole. Without this, audio is routed exclusively to BlackHole and the speakers are silent. This is now documented prominently in the README.
  • →Dual FFT resolution is worth the code complexity — early versions used a single 1024-point FFT for both views. The waveform was fine, but the spectrogram looked smeared with poor frequency distinction between close pitches. Adding a second scipy.signal.spectrogram computation at 2048 points dramatically improved spectrogram quality at minimal performance cost.
  • →ARM64-native Python matters for PortAudio on Apple Silicon — Rosetta-translated x86_64 Python worked for computation but caused PortAudio to misidentify audio devices and occasionally crash on M1/M2 machines. Requiring ARM64 Homebrew Python eliminated these issues.
  • →PyQtGraph is significantly faster than matplotlib for real-time visualization — an early matplotlib prototype with blitting ran at 8fps before dropping frames. Switching to PyQtGraph (OpenGL-backed) achieved stable 15fps with the same data volume and better color accuracy.
Status

Functional — personal desktop tool, distributable as macOS .app