Cava Spectrum Analyzer

SkillMedia

Implementation and maintenance guide for NullPlayer's standalone and embedded Cava spectrum analyzers, including DSP, rendering, scoped settings, skin-following colors, resets, UI-family switches, and window lifecycle.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Cava Spectrum Analyzer skill

What this skill tells your AI

The instructions your AI receives, as published by ad-repo/nullplayer in skills/cava/SKILL.md and read by ahel’s review.

Cava is a responsive, bar-based audio spectrum analyzer window built on a clean-room Swift reimplementation of the cava algorithm (https://github.com/karlstav/cava, MIT-licensed).

Accessing Cava

Open Windows > Cava in the menu bar or right-click any main window to toggle the standalone Cava window. Cava is also available inside the 76×16 main-window visualization area via Visuals > Main Window > Mode > Cava.

Modern and Metal Library and Compact Window surfaces can also use Cava as a full-window backdrop via Visuals > Library Window > Cava, Visuals > Compact Window > Cava, or the corresponding surface's context menu. Each backdrop has its own settings scope and lifecycle; neither borrows the standalone or main-window presenter. The window menus expose Cava, Art, and Cava + Art separately. Art always uses the legacy browser list-area renderer and geometry; there is no second full-window artwork renderer. Both windows default to Cava + Art on first use while preserving an existing saved selection. On upgrade, the legacy showBrowserArtworkBackground choice seeds only absent window-specific keys: enabled becomes Cava + Art and disabled becomes Cava, so adopting Cava does not re-enable artwork.

Cava is the shipped default for the embedded main-window visualization in every bundled modern skin and every built-in Metal finish. Classic UI continues to default the embedded visualization to vis_classic. A user's persisted mode still wins on a normal same-skin launch; selecting or resetting a modern/Metal skin reapplies that skin's Cava default.

What It Shows

Cava renders real-time audio spectrum as vertical bars:

  • Mono mode: Single row of bars growing up from the bottom edge, reflecting the two channels combined (see the mono magnitude-averaging note under Gotchas)
  • Stereo mode: Mirrored layout — left channel grows upward (top half), right channel grows downward (bottom half)

Each bar's color is interpolated between a low color (short/quiet bars) and a high color (tall/loud bars) — i.e. by bar height / intensity, NOT by frequency. Many named presets are available (see Right-Click Menu), including metallic gradients. Bar heights respond in real time to audio content; decay and smoothing are built into the DSP.

Audio Path

Cava consumes the full-stereo audio tap from AudioEngine. The tap is emitted by both playback paths:

  • Local file playback via AVAudioEngine
  • Streaming audio via AudioStreaming library

The tap fires at the source rate (typically 44.1/48 kHz), producing 2048 samples per notification. The local AVAudioEngine path posts from its audio callback; the streaming path coalesces onto the main queue before AudioEngine forwards the notification. CavaRenderModel observes with queue: nil, marshals buffer ownership to the main thread, and schedules all CavaCore access on its serial processing queue.

DSP (CavaCore Algorithm)

CavaCore implements the cava spectrum analyzer in pure Swift using Accelerate vDSP. Pipeline per channel, in order:

  1. Dual FFT: Parallel bass (4096-point DFT) and treble (2048-point DFT, used above 4 kHz). Each real input chunk receives a matching-length Hann window before any zero-padding; the normal 2048-sample tap is therefore Hann-windowed at 2048 samples before entering the 4096-point bass DFT.
  2. Contiguous log bands, energy ÷ bin-count exponent: Each bar maps to a non-overlapping log-spaced frequency band [edge(n), edge(n+1)) (50 Hz–10 kHz); its value is the summed magnitude divided by binCount^bandExponent. Plain sum over-weights high bars (treble bands span far more FFT bins than bass bands) and suppresses bass; mean does the opposite. Exponent bandExponent (default 0.3) is the tilt knob: sum (÷N^0) = brightest, mean (÷N^1) = bassiest, and √N (÷N^0.5) is the neutral midpoint. The 0.3 default keeps bass strong while letting mid/high frequencies read clearly.
  3. Monstercat neighbor smoothing: Spatial blur across adjacent bars (stateless).
  4. Integral/exponential smoothing: Per-bar temporal EMA, alpha = 1 - noiseReduction (app default 0.65). Higher noiseReduction = smoother but less dynamic.
  5. Autosens (before gravity): A persistent gain (sens) scales the magnitudes. See the autosens gotcha for the fast-attack/slow-release + deadband + low-start design.
  6. Clamp to [0,1], then gravity/falloff: Instant rise, gravity fall. Clamping BEFORE gravity keeps peakValues in the normalized domain (a pre-convergence spike can't poison it). See the gravity gotcha for the >= requirement.

Output: Per-channel bar arrays in 0…1 range. The order matters: autosens must run before gravity, and gravity operates in the normalized domain, or bars never decay to zero on silence.

Rendering

CavaDrawing (CoreGraphics, mode-neutral) renders gradient bars:

  • Interpolates each bar's color between the low- and high-intensity colors by bar height (not by frequency)
  • Draws solid rectangles per bar with subtle borders for definition
  • Handles both mono (single row) and stereo (mirrored L/R) layouts
  • Supports a center-out mirrored mono layout for full-window backdrops, duplicating the combined spectrum across both horizontal halves so the visualization fills the complete width

CavaRenderModel drives a 60 Hz scheduler on the main thread while confining all DSP to one serial worker queue:

  • The audio tap (~21 Hz) stashes the latest L/R buffer. On the next tick the worker calls CavaCore.analyze(_:) (the FFTs) once per new buffer, then CavaCore.render() (monstercat/smoothing/autosens/gravity) on render ticks so decay/smoothing advance at the display rate. Only the finished bar arrays return to the main thread. execute(_:) (= analyze + render) is kept for tests/one-shot callers.
  • At most one worker operation is outstanding. Incoming audio is coalesced to the newest buffer while it is busy, preventing a queue backlog during other expensive UI operations.
  • Normal playback reads settings on new-audio ticks rather than 60×/sec. Explicit menu/double-click changes call settingsDidChange() so mode and tuning also update immediately while paused or stopped.
  • Pause-freeze: if no new audio arrives for >~6 ticks (~100 ms), the timer stops re-running the stale buffer and holds the last frame. Re-running a static buffer indefinitely would let autosens hunt and the display throb; freezing keeps a paused Cava perfectly still.
  • Idle-skip: a per-frame signature detects settled bars and skips the redraw (not the DSP), so a static display costs no repaint.
  • Calls onNeedsDisplay (the views invalidate their content/animation rect) only when bars change.

Both classic and modern views call CavaDrawing.draw() with current bar data, low/high colors, and mode. The embedded main-window instance uses its own CavaPresenter(scope: .mainWindow), always renders mono, and has a scope-distinct audio consumer and processing queue so it can run independently beside the standalone window. The Library and Compact backdrops use CavaPresenter(scope: .libraryWindow) and CavaPresenter(scope: .compactWindow) respectively. Library defaults to Mono and Compact defaults to Stereo with Smooth (0.80) temporal smoothing. Both browser scopes default to 64 bars. Backdrop Mono is center-out mirrored so the combined spectrum occupies the full width. CompactBackdropView always draws into its full bounds; its frame must be the window content container's bounds, not the browser view's frame, so resizing or an offset browser layout cannot confine the visualization to part of the window. Its layer mask must also receive the browser's current sharpCorners: when the Library docks, the backdrop and browser siblings must square the same joined corners or a rounded transparent wedge appears beneath the translucent browser. On the first Off → Cava transition, invalidate and lay out the complete backdrop/browser sibling subtree immediately and again on the next main-run-loop turn. The browser was previously rendered opaque, and an ordinary view-only invalidation can leave a horizontal region of cached opaque content covering either Mono or Stereo until the window is reconstructed. Art-only does not create CompactBackdropView. Cava + Art keeps Cava in that full-window sibling while the browser draws exactly one legacy-sized artwork image in its established list-area path. Playing-track and selection-driven artwork loads may run concurrently, but share a display generation. They may populate their own caches after a newer request begins; only the newest generation may assign currentArtwork.

Window Layout

  • Both modes: Single-height center-stack window (like Flow / NetworkMonitor / Spectrum)
  • Classic: SkinRenderer draws border-only chrome (title "CAVA" + close button)
  • Modern: ModernSkinRenderer chrome with spectrum_* style elements (title bar + close button)
  • Whole-face drag: Click title bar or content area to drag; double-click anywhere toggles Mono ⇄ Stereo; close button in top-right
  • Hide Title Bars (modern): Modern center-stack subwindows hide their titlebar whenever docked; the global Hide Title Bars setting also hides it while detached. The close button is then unreachable, but the content remains draggable. Classic Cava always keeps its classic chrome.
  • Docking: Participates in center-stack docking (snaps below main/other windows)

Right-Click Menu

  • Mono / Stereo: Toggle between single-row and mirrored layouts (double-click the window does the same)
  • Color: Submenu with Match Skin at the top, then named gradient presets (each with a low→high swatch and a checkmark on the active one). Presets include standard combos (Blue → Magenta, Fire, Ice, Vaporwave, Aurora, Ocean, Neon, …) and a metallic set (Gold, Silver, Copper, Bronze, Gunmetal). Selecting a preset sets lowGradientColor/highGradientColor, sets hasCustomColors = true, and persists until the next explicit skin change. Match Skin clears hasCustomColors immediately so the gradient follows the active skin again (see below).
  • Transparent Background (modern only): Defaults to the active skin: on when window.opacity < 1, off when the skin is opaque. Toggling it on drops the window background to the skin's window.opacity; toggling it off makes Cava opaque. Not shown in classic (classic Cava is always opaque black). An explicit skin or UI-family change applies the incoming skin's default; a same-skin relaunch preserves the user's choice.
  • Bars: Bar-count presets (16 / 24 / 32 / 48 / 64).
  • Smoothing: Temporal smoothing / latency (noiseReduction): Snappy (0.50) · Balanced (0.65, default) · Smooth (0.80) · Very Smooth (0.90). Lower = more real-time but livelier; higher = smoother but laggier.
  • Bass: Bass↔treble tilt (bandExponent): Less (0.15) · Balanced (0.30, default) · More (0.50) · Max (0.70).
  • Reset to Defaults: Restores Bars / Smoothing / Bass to factory defaults (CavaSettings.resetTuning()); leaves mode, colors, and transparency untouched.
  • Close: Hide the window

When Cava is a Library or Compact Window backdrop, the same mode, color, bar-count, smoothing, bass, and reset controls appear under the corresponding Visuals submenu and in that surface's context menu. Transparency and Close are omitted because the backdrop is owned by its browser surface. Compact entry points are gated by AppCapabilities.supports(.compactWindowVisualsMenu); keep new Compact Window backdrop menu entry points behind the same capability.

The menu is built and handled by CavaPresenter itself (an NSObject with @objc actions targeting self); the view only supplies the onNeedsDisplay / onNeedsFullDisplay / onClose closures. Changing Bars / Smoothing / Bass updates CavaSettings; CavaRenderModel.settingsDidChange() applies the change immediately and rebuilds CavaCore when bar count, sample rate, noiseReduction, or bassTilt differs.

Persistence (AppStateManager)

  • Window visibility/frame: Visibility is restored in either UI mode. The exact saved frame is restored only when the saved and running UI modes match; otherwise Cava opens at the target mode's default stack position. During a live cross-family skin switch, a detached Cava window keeps its exact floating frame through the temporary UI Size collapse, including when Compact Window is the active main surface; docked Cava geometry is recomputed with the target stack.
  • Durable preferences: CavaSettings (UserDefaults) — mode selection, bar count, gradient colors — persist independently of Remember State
  • Restoration: On launch, if Cava was visible, showCava(at:) repositions it at the saved frame (or default stack position if no frame saved)

Settings (CavaSettings)

Durable UserDefaults-backed preferences:

KeyTypeDefaultNotes
cavaModeInt (enum)1 (stereo)0=mono, 1=stereo
cavaBarCountInt321–128; clamped on set
cavaLowGradientColorNSColor (archived)Bright blue (0, 0.3, 1)Low-intensity (short-bar) color; used only when cavaColorsCustomized
cavaHighGradientColorNSColor (archived)Magenta (1, 0, 1)High-intensity (tall-bar) color; used only when cavaColorsCustomized
cavaColorsCustomizedBoolfalseIf false, colors follow the skin (Match Skin)
cavaTransparentBackgroundBoolIncoming skin (window.opacity < 1); raw fallback falseModern-only translucent background
cavaTransparencyCustomizedBoolfalseDistinguishes an explicit user choice from the skin-derived transparency default
cavaNoiseReductionDouble0.65Smoothing / latency (0…0.95)
cavaBassTiltDouble0.30Bass↔treble tilt (bandExponent, 0…1)

Access via CavaSettings.mode, CavaSettings.barCount, etc. Menu and double-click changes take effect immediately; settings that alter DSP construction recreate CavaCore through settingsDidChange().

CavaSettings.Scope separates .cavaWindow, .mainWindow, .libraryWindow, and .compactWindow. The legacy static properties above remain wrappers for .cavaWindow and continue using the existing keys. Scope-aware accessors use cava.mainWindow.* for the embedded analyzer, cava.libraryWindow.* for the regular Library backdrop, and cava.compactWindow.* for the Compact backdrop. Main-window mode always resolves to Mono. Library mode defaults to Mono, while Compact mode defaults to Stereo; both preserve an explicit mode choice. Compact smoothing defaults to 0.80, while the other scopes default to 0.65. Library and Compact bar count defaults to 64; standalone and main-window Cava remain at 32. Each scope's tuning and color choices are independent. Bar-count, smoothing, and bass menu presets are canonical CavaSettings collections shared by the presenters.

Visualization Reset and UI-Family Switches

Standalone Cava and Reset All

Cava colors fall back to the active skin's gradient, pushed by ModernCavaView or CavaView. The embedded main-window Cava keys (.mainWindow) live in VisualizationPreferences.mainWindowKeys. Library (.libraryWindow) and Compact (.compactWindow) backdrop keys live in their visualization reset sets. The standalone window keys (CavaSettings.preferenceKeys(for: .cavaWindow)) live in cavaWindowKeys, which is included only in the .all reset scope. Reset All clears all four scopes and restores Library's Mono/64-bar default and Compact's Stereo/Smooth/64-bar first-use defaults. It also clears libraryBackdropMode and compactBackdropMode, restoring both visual selections to Cava + Art.

VisualizationPreferences.reset(.all) therefore resets every Cava scope. Its notification phase calls WindowManager.refreshCavaWindowAfterReset() and then the open controller's refreshAfterReset(). The view re-reads tuning with presenter.settingsDidChange() and re-derives the active skin's default gradient through skinDidChange(). The standalone window is intentionally not included in .mainWindow or .spectrumWindow resets.

Skin-Owned Appearance Across UI Families

CavaSettings.hasCustomColors is persisted independently for every Cava scope. When a scope is customized, effectiveLowColor and effectiveHighColor ignore that scope's in-memory skin default. CavaSettings.transparentBackground applies only to the standalone modern/metal window. An explicit UI-family switch is a skin change and calls CavaSettings.resetAppearanceForSkinChange(transparentBackground:), which clears all custom-color flags and applies the incoming skin's Cava transparency default. Modern skins derive that default from window.opacity < 1; classic passes false:

  • Entering modern or metal clears them in ModernSkinEngine.configureSkinDependencies(preservePersistedProfiles:) when persisted visualization preferences are not being preserved.
  • Entering classic clears them in WindowManager.prepareUIRuntime(for:) via CavaSettings.resetAppearanceForSkinChange(), because the classic branch does not reload a skin through ModernSkinEngine.

Without this reset, a modern-picked gradient or transparency override survives into an unrelated skin or UI family. Do not hard-code false for every incoming skin: that makes Glass skins opaque. Any new UI-family-entry path must apply the target skin's default with the same semantics. A normal same-skin app launch preserves the user's override. Track that distinction with cavaTransparencyCustomized: menu toggles set it; skin changes clear it; launch and Reset All reapply the current skin default only while it is false. This also repairs stale uncustomized values written by older reset logic without discarding a real user choice.

Key Files

FileRole
Cava/CavaSettings.swiftUserDefaults-backed preferences (mode, bar count, colors)
Cava/CavaRenderModel.swiftObserves audio tap, feeds CavaCore, 60 Hz timer, idle skip
Cava/CavaDrawing.swiftCoreGraphics bar renderer (mode-neutral)
Cava/CavaPresenter.swiftShared runtime, menu builder, display notifications
App/CavaWindowProviding.swiftProtocol for classic/modern implementations
Windows/Cava/CavaWindowController.swiftClassic window controller (NSWindowController)
Windows/Cava/CavaView.swiftClassic view (NSView, draws + handles drag/clicks)
Windows/ModernCava/ModernCavaWindowController.swiftModern window controller
Windows/ModernCava/ModernCavaView.swiftModern view (NSView, modern skin renderer, corner radius)
App/WindowManager.swiftIntegration: showCava(), toggleCava(), cavaWindowFrame, center-stack logic
App/ContextMenuBuilder.swiftMenu item: "Cava" in Windows menu + toggleCava() action
Windows/MainWindow/MainWindowView.swiftClassic inline Cava rendering + lifecycle
Windows/ModernMainWindow/ModernMainWindowView.swiftModern inline Cava rendering + lifecycle
Windows/ModernLibraryBrowser/CompactBackdropView.swiftShared Library/Compact Cava presenter, lifecycle, and full-surface drawing
Windows/ModernLibraryBrowser/ModernLibraryBrowserView.swiftLibrary/Compact backdrop sizing, translucency, menus, and artwork routing
App/AppStateManager.swiftState capture/restore: isCavaVisible, cavaWindowFrame
NullPlayerCore/Audio/CavaCore.swiftDSP engine (pure Swift vDSP FFT + smoothing)

Gotchas

Mode Independence (Hard Rule)

Files in Cava/ must NOT import Skin/ or ModernSkin/. Files in Windows/ModernCava/ must NOT import Skin/ or Windows/MainWindow/. Coupling only via:

  • WindowManager (via provider protocol)
  • AudioEngine (shared service, no UI dependency)
  • CavaSettings (UserDefaults enum)
  • Shared models (NSColor, NSRect, etc.)

Audio Tap Availability

Both playback paths (local + streaming) emit the stereo tap:

  • Local: AVAudioEngine PCM tap installed at engine setup
  • Streaming: AudioStreaming library's real-time PCM tap (different implementation, same notification)

Critical: If only one playback path is in use, Cava will update while that path plays. The tap is idled when Cava is hidden (no consumer registered). The standalone and embedded render models use different consumer IDs, so opening or hiding either one cannot unregister the other's tap demand.

Notification Threading

Notification.Name.audioStereoPCMFullDataUpdated arrives from different queues: local playback posts from the audio callback, while streaming playback forwards it on the main queue after coalescing. CavaRenderModel observes with queue: nil and explicitly marshals buffer assignment to main via DispatchQueue.main.async. The timer then coalesces the newest buffer onto the serial Cava processing queue; only completed bar arrays and display invalidation return to main. Never touch UI or run FFT work directly from the observer block.

Gravity must rise/hold on >=, not > (the big jitter bug)

applyGravityAndFalloff had if bars[i] > peakValues[ch][i] (strict). At steady state bars[i] == peakValues, so it fell through to the decay branch, subtracted the falloff (0.05), then snapped back up the next frame — a self-sustaining period-2 flicker of amplitude 0.05 on every bar, even on a perfectly constant signal. Barely visible on tall bars, a violent ±60% strobe on short bars ("short bars jitter intensely"). Fix: rise/hold on >=, and clamp the gravity fall so it never undershoots the current value (max(bars[i], peak - falloff)). Constant-input test coverage must remain steady.

Autosens is a persistent gain: low start, fast attack, slow release, deadband

sens is a single persistent gain (not a per-frame AGC), applied to magnitudes before gravity. Design, learned the hard way:

  • Start LOW (1e-6) and grow into the signal during sensInit (×1.2/frame until first overshoot). Starting at 1.0 was far too high for summed-energy magnitudes, so every bar clipped at full scale for ~5 s at launch while the gain ground down. Starting low means bars ramp up from small instead of pinning.
  • Hold gain on digital silence. Initial grow-in and later recovery are gated on a non-silent raw magnitude. Growing sens during leading silence can hit its cap before the first audible sample and pin the display for tens of seconds.
  • Gentle attack (×0.98) on overshoot, NOT an aggressive proportional attack — a hard attack ducks the whole spectrum on every transient (reads as pumping/jitter). A single bar briefly touching the ceiling is normal.
  • Deadband: only recover (×1.001) when the peak is well below the ceiling (< 0.85); hold the gain steady in [0.85, 1.0]. Otherwise the gain hunts up-into-clip and back — a global throb, very visible when paused.
  • Do NOT re-introduce a per-frame "divide by the running peak" AGC: it re-inflates a decaying tail so bars never fall to zero on silence.

Mono averages magnitude spectra, not the time-domain signals

Mono is NOT (L+R)*0.5 in the time domain — summing stereo material comb-filters it (phase differences create moving spectral notches = jitter that only appears in mono). Instead CavaRenderModel always runs CavaCore with channels: 2 and, for mono display, averages the two channels' output bar arrays. Channel count therefore never changes on a mode switch; bar count, sample rate, smoothing, and bass tilt can rebuild the core.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
118
Forks
9
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
cava
Source
github.com/ad-repo/nullplayer