Audio engine architecture, local/streaming pipelines, EQ, spectrum analysis, and playback flow. Use when working on audio playback, streaming, equalization, spectrum visualization, BPM detection, or ProjectM integration.
This guide describes NullPlayer's audio playback system, including local file playback, streaming audio, equalization, spectrum analysis, and waveform generation.
NullPlayer uses two parallel audio pipelines to handle different content types:
| Content Type | Pipeline | EQ Support | Spectrum | Waveform |
|---|---|---|---|---|
| Local files (.mp3, .flac, etc.) | AVAudioEngine | Yes | Yes | Cached 4096-bucket snapshot |
| HTTP streaming (Plex/Subsonic/Jellyfin/Emby/radio) | AudioStreaming library | Yes | Yes | Live stream accumulator from 576-sample PCM chunks |
Both pipelines support the active EQ layout for the current UI mode and real-time spectrum visualization. Classic mode uses the legacy 10-band layout; modern mode uses a 21-band layout. EQ settings are automatically synchronized between them. The waveform window reuses the same playback sources: local files use cached snapshots, while streams start with live accumulation and may promote to a cached seekable snapshot when prerendering is available.
┌─────────────────────────────────────────────────────────────────────┐
│ AudioEngine │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ LOCAL FILES STREAMING (Plex/Subsonic) │
│ ──────────── ────────────────────────── │
│ │
│ ┌──────────────┐ ┌─────────────────────────────┐ │
│ │ AVAudioFile │ │ StreamingAudioPlayer │ │
│ └──────┬───────┘ │ (AudioStreaming lib) │ │
│ │ │ │ │
│ ▼ │ ┌───────────────────────┐ │ │
│ ┌──────────────┐ │ │ HTTP URL → Decode → │ │ │
│ │ playerNode │─────────┐ │ │ PCM buffers │ │ │
│ │ (primary) │ │ │ └───────────┬───────────┘ │ │
│ └──────────────┘ │ │ │ │ │
│ │ │ ▼ │ │
│ ┌──────────────┐ │ │ ┌───────────────────────┐ │ │
│ │crossfadeNode │─────────┼──► mixerNode ─► eqNode ─► limiter │ │
│ │ (for Sweet │ │ │ │ AVAudioUnitEQ │ │ │
│ │ Fades) │ │ │ └───────────┬───────────┘ │ │
│ └──────────────┘ │ │ │ │ │
│ │ │ ▼ │ │
│ │ │ ┌───────────────────────┐ │ │
│ │ │ │ Spectrum Tap │ │ │
│ │ │ │ (frameFiltering) │ │ │
│ │ │ └───────────────────────┘ │ │
│ ▼ └─────────────────────────────┘ │
│ ┌──────────────┐ │
│ │ mixerNode │ EQ settings sync ◄──────────► │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ eqNode │ │
│ │ (mode EQ) │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ limiterNode │ │
│ │ (Anti-clip) │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │mainMixerNode │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Output Node │ ─────► Speakers / Audio Device │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Audio/AudioEngine.swift)Main audio controller managing:
Key Properties:
private let engine = AVAudioEngine()
private let playerNode = AVAudioPlayerNode()
private let crossfadePlayerNode = AVAudioPlayerNode()
private let activeEQConfiguration: EQConfiguration
private let eqNode: AVAudioUnitEQ
private let limiterNode = AVAudioUnitDynamicsProcessor()
private var streamingPlayer: StreamingAudioPlayer?
private var crossfadeStreamingPlayer: StreamingAudioPlayer?
var gaplessPlaybackEnabled: Bool
var volumeNormalizationEnabled: Bool
var sweetFadeEnabled: Bool
var sweetFadeDuration: TimeInterval
Shuffle-specific behavior in AudioEngine:
playNow, and empty-queue insertion all start playback from the shuffled currentIndex, not from index 0.Audio/StreamingAudioPlayer.swift)Wrapper around AudioStreaming library:
Why separate EQ? AVAudioNode instances can only be attached to one AVAudioEngine. Since AudioStreaming uses its own internal engine, we maintain a separate EQ node that stays synchronized with the main engine's EQ.
When switching streaming tracks, avoid race conditions with an isLoadingNewStreamingTrack flag:
private var isLoadingNewStreamingTrack: Bool = false
private func loadStreamingTrack(_ track: Track) {
isLoadingNewStreamingTrack = true
// DON'T call stop() before play() - AudioStreaming handles this internally
streamingPlayer?.play(url: track.url)
state = .playing
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.isLoadingNewStreamingTrack = false
}
}
func streamingPlayerDidFinishPlaying() {
guard !isLoadingNewStreamingTrack else { return }
trackDidFinish()
}
See skills/local-library/SKILL.md — NAS Responsiveness section.
Classic mode uses the legacy 10-band configuration; modern mode uses a 21-band configuration derived from EQConfiguration.modern21. Both pipelines build their AVAudioUnitEQ from the same active layout at launch.
| Band | Frequency | Filter Type | Bandwidth |
|---|---|---|---|
| 0 | 60 Hz | Low Shelf | 2.0 octaves |
| 1 | 170 Hz | Parametric | 2.0 octaves |
| 2 | 310 Hz | Parametric | 2.0 octaves |
| 3 | 600 Hz | Parametric | 2.0 octaves |
| 4 | 1 kHz | Parametric | 2.0 octaves |
| 5 | 3 kHz | Parametric | 1.5 octaves |
| 6 | 6 kHz | Parametric | 1.5 octaves |
| 7 | 12 kHz | Parametric | 1.5 octaves |
| 8 | 14 kHz | Parametric | 1.5 octaves |
| 9 | 16 kHz | High Shelf | 1.5 octaves |
Frequencies: 31.5, 45, 63, 90, 125, 180, 250, 355, 500, 710, 1000, 1400, 2000, 2800, 4000, 5600, 8000, 11200, 14000, 16000, 20000
First band: lowShelf
Last band: highShelf
Middle bands: parametric
Parametric bandwidth: 1.0 octave
Per-band gain: -12 dB to +12 dB
Preamp (global gain): -12 dB to +12 dB
Disabled by default to preserve original audio quality
Transparent limiter (threshold: -1 dB) prevents clipping
Saved EQ arrays are remapped between 10-band and 21-band layouts when restoring across classic/modern mode switches
| Control | Behavior |
|---|---|
| ON toggle | Enable/disable EQ |
| AUTO toggle | Apply genre-based preset for current track; auto-enables EQ if off |
| FLAT ROCK POP ELEC HIP JAZZ CLSC buttons | Apply preset and highlight active button; auto-enables EQ if off; clicking active button deactivates (applies flat, no highlight) |
| Drag a fader | Adjust that EQ band; clears active preset highlight |
| Double-click a fader | Reset that band only to 0 dB (not all bands) |
Integrated PRE control | Adjust global preamp from -12...+12 dB; double-click resets to 0 dB |
Modern EQ specifics:
PRE control in the graph/header strip instead of a dedicated left preamp slider1K, 1.4K, 2K, etc.)When EQ settings change, both pipelines are updated:
func setEQBand(_ band: Int, gain: Float) {
let clampedGain = max(-12, min(12, gain))
eqNode.bands[band].gain = clampedGain // Local pipeline
streamingPlayer?.setEQBand(band, gain: clampedGain) // Streaming pipeline
}
Both pipelines feed spectrum data to the UI for visualization.
| Mode | Gain Control | Preserves Balance | Best For |
|---|---|---|---|
| Accurate | Fixed dB mapping | Yes (true levels) | Technical analysis |
| Adaptive | Global adaptive | Yes (scaled together) | General listening |
| Dynamic | Per-region (bass/mid/treble) | No (independent) | Visual appeal |
Both local and streaming use identical 2048-point FFT for consistent visualization:
Spectrum analyzer and ProjectM show audio levels independently of user volume:
Local: Tap on mixerNode before volume control (mainMixerNode.outputVolume)
Streaming: AudioStreaming's frameFiltering captures after volume, so processAudioBuffer() compensates by dividing samples by current volume (capped at 20x).
The waveform window shares the audio engine but intentionally does not share the same demand gate as FFT/spectrum analysis.
WaveformCacheService opens the active file with AVAudioFile~/Library/Application Support/NullPlayer/WaveformCache/AudioEngine and StreamingAudioPlayer emit .audioWaveform576DataUpdatedBaseWaveformView listens only for non-file audio tracksStreamingWaveformAccumulator builds progressive seekable waveforms for timed streams, and rolling non-seekable waveforms for live/radio streamsWaveformCacheService can prerender remote waveforms and persist them as seekable snapshotsWaveformCacheService.serviceCacheKey(serviceIdentity:duration:bitrate:sampleRate:)AVAssetReader first, then falls back to URL download + local decodeBaseWaveformView freezes on that snapshot and ignores subsequent live 576-sample chunks for the same trackDo not assume spectrum demand implies waveform demand.
AudioEngine.spectrumConsumers controls FFT/spectrum workAudioEngine.waveformConsumers controls 576-sample waveform chunk generationSpectrumAnalyzerView registers a waveform consumer only while qualityMode == .visClassicExact and the analyzer is actively renderingThis split matters for CPU usage: hidden waveform windows and inactive vis_classic views should not keep paying the live waveform callback cost.
Real-time BPM detection using aubio library (libaubio.5.dylib):
aubio_tempo_t performs onset detection + beat tracking.bpmUpdated notification (throttled to 1/second)Integration:
AudioEngine and StreamingAudioPlayer own a BPMDetectorKey Files:
Audio/BPMDetector.swiftAudio/AudioEngine.swiftAudio/StreamingAudioPlayer.swiftPCM data is pushed directly via NotificationCenter:
NotificationCenter.default.post(
name: .audioPCMDataUpdated,
object: self,
userInfo: ["pcm": pcmSamples, "sampleRate": sampleRate]
)
Eliminates polling latency - total latency now 15-20ms (down from 60-80ms).
When audio isn't playing:
MP3, M4A, AAC, WAV, AIFF, FLAC, ALAC, OGG
HTTP/HTTPS URLs with MP3, AAC, Ogg Vorbis
M4A Limitation: Only "fast-start" optimized M4A files supported. Non-optimized M4A (moov atom at end) will fail. Fix with: ffmpeg -i input.m4a -movflags +faststart output.m4a
PlexPlaybackReporter) and video (PlexVideoPlaybackReporter)JellyfinPlaybackReporter) and video (JellyfinVideoPlaybackReporter)EmbyPlaybackReporter) and video (EmbyVideoPlaybackReporter)| Library | Purpose | Version |
|---|---|---|
| AVFoundation | Local file playback, EQ | System |
| Accelerate | FFT for spectrum analysis | System |
| CoreAudio | Output device management | System |
| AudioStreaming | HTTP streaming | 1.4.0+ |
| aubio | BPM/tempo detection | 0.4.9+ |
For detailed information, see:
| Area | Files |
|---|---|
| Core | Audio/AudioEngine.swift, Audio/StreamingAudioPlayer.swift |
| EQ | EQ node configuration in AudioEngine, StreamingAudioPlayer |
| Spectrum | Audio/AudioEngine.swift (FFT processing) |
| BPM | Audio/BPMDetector.swift |
| Output devices | Audio/AudioOutputManager.swift |
| Track URL resolution | Audio/StreamingTrackResolver.swift |
| File validation | Audio/AudioFileValidator.swift |
| ProjectM | Visualization/ProjectMWrapper.swift, Windows/ProjectM/ |
| Reporters | Plex/PlexPlaybackReporter.swift, Plex/PlexVideoPlaybackReporter.swift, Subsonic/SubsonicPlaybackReporter.swift, Jellyfin/JellyfinPlaybackReporter.swift, Jellyfin/JellyfinVideoPlaybackReporter.swift, Emby/EmbyPlaybackReporter.swift, Emby/EmbyVideoPlaybackReporter.swift |