Audio-reactive desktop wallpaper for macOS. Live system audio drives Metal-rendered effects painted directly onto every connected display.
- Three Metal effects —
Spectrum(96-bin chromatic FFT bars),Orb(amplitude-driven pulsing disc with glow),Border(vsync-driven trail with quintic-Bézier corners and beat-reactive echo). - System audio without a microphone — taps the Core Audio output bus via
CATapDescription(macOS 14.4+ Process Tap API). Choose between global system audio or any single running app. - Per-screen renderer — each display owns its own
CAMetalDisplayLinkon a dedicated thread, so dual-screen ProMotion + 60Hz setups don't fight each other for the main runloop. - Scene / layer model — stack any combination of effects, save scene presets, switch live from the menu bar.
- Plugin-friendly architecture — adding a fourth effect type touches one new file plus a single
EffectRegistry.register(...)line. The renderer, coordinator, Codable layer, and UI dispatch all read from the registry. - Always-on perf instrumentation —
RenderMetricsemits a per-renderer heartbeat (fps / avg / max / cb-gap) at info-level OSLog, free unless youlog showfor it. - Menu bar app (LSUIElement), optional launch-at-login, runs at
desktopWindowlevel so other windows sit on top.
- macOS 14.4+ (Core Audio Process Tap API)
- Xcode 16+
- XcodeGen —
brew install xcodegen— for generatingSpectraWall.xcodeprojfromproject.yml
brew tap giggs-lynx/tap
brew install --cask spectrawallThe binary is unsigned. First launch goes through System Settings → Privacy & Security → "Open Anyway".
git clone https://github.com/giggs-lynx/SpectraWall.git
cd SpectraWall
make project
open SpectraWall.xcodeprojBuild and run in Xcode (⌘R). Debug builds re-prompt the audio-tap permission every time the binary is recompiled (unsigned cdhash changes); Release builds keep the grant once given.
- Click the SpectraWall icon in the menu bar.
- Pick an audio source — System (all output) or a specific running app.
- Open Settings… to manage scenes, add/remove layers, tune per-effect parameters and colours.
- Toggle per-display visibility under General → Displays.
Settings live in XDG Base Directory locations:
| Path | Purpose |
|---|---|
~/.config/spectrawall/config.json |
Global prefs (active scene, enabled display IDs, motion style, scene order) |
~/.config/spectrawall/scenes/scene-<uuid>.json |
One file per scene |
~/.local/state/spectrawall/state.json |
First-launch sentinel |
Audio path:
CATapDescription tap (Core Audio)
→ Aggregate device + IO proc
→ handleAudioBuffer (interleaved L/R samples)
→ AudioAnalyzer (Accelerate vDSP FFT, 96 chromatic bins)
→ AudioDataBus (Combine PassthroughSubject<StereoBins>)
→ each effect's onAudio(_) on its renderer queue
Render path:
CAMetalDisplayLink (per renderer, dedicated thread + runloop)
→ renderQueue.sync { renderer.draw(...) }
→ tick all frame clients (effect.onTick)
→ submit meshes per EffectType
→ encode pipelines grouped by type (back-to-front by renderOrder)
Plugin model:
EffectRegistry.bootstrap() lazily on first access
→ register({Spectrum,Orb,Border}Effect.descriptor)
EffectDescriptor knows:
- displayName / iconAssetName / renderOrder
- makeDefaultSettings / settingsCodec (Codable round-trip)
- makeEffect (factory) / makeSettingsView (SwiftUI)
- pipelineSpec (shader names + blend mode)
- Add a static member to
EffectType(e.g.static let ripple = EffectType(rawValue: "Ripple")). The rawValue is the on-disk key. - Define an
EffectSettings-conforming struct for its persistent parameters. - Write a SwiftUI settings section view for it.
- Subclass
BaseEffect, implementonTick(_:)/onAudio(_:)and any setting-change hooks. - Add shader functions to
Effects.metal(or reuse existing ones). - Expose a
static let descriptor: EffectDescriptorand append oneregister(...)call insideEffectRegistry._bootstrapped.
That's the whole surface — coordinator, renderer, LayerSettings Codable, popover menu and Settings panel all pick it up.
SpectraWall/
├── App/ AppDelegate, App entry, AppLog, AppConstants
├── Audio/ Core Audio tap + process monitor + Accelerate FFT + Combine bus
├── Metal/ EffectRenderer + EffectRendererRegistry + EffectsHostView
│ + RenderMetrics + Effects.metal (shaders)
├── Model/ AppConfig / AppState / AppSettings / SceneSettings / LayerSettings
│ + per-effect *Settings structs + ColorData + XDGStorage
├── Visualizer/ EffectsCoordinator + VisualizerSceneManager
│ └── Effects/ Effect protocol + BaseEffect + EffectType +
│ EffectDescriptor + EffectRegistry + EffectSettingsCodec
│ + {Spectrum,Orb,Border}Effect + BorderEffectGeometry
└── UI/ SwiftUI Settings panel + popover + reusable components
RenderMetrics is always-on, zero-cost when no subscriber is attached:
log show --predicate 'subsystem == "com.spectrawall.app" AND category == "Render"' \
--info --last 5mEach renderer emits a heartbeat every ~2s:
[perf] display=2 fps=60.0 avg=1.7ms max=4.4ms slow8ms=0% (0/120)
cb-gap-avg=16.66ms cb-gap-max=22.4ms
fps/avg/max— frame timing onrenderQueueslow8ms— fraction over the ProMotion budgetcb-gap-*— interval betweenCAMetalDisplayLinkcallbacks (vsync delivery health)
make release 0.0.xVerifies the working tree is clean and HEAD matches the pushed origin/main, then signs and pushes the v0.0.x tag. GitHub Actions takes it from there:
make dist VERSION=0.0.x— Release build, zip, sha256- Creates a GitHub Release with the zip attached
- Updates
giggs-lynx/homebrew-tap'sCasks/spectrawall.rbsobrew upgrade --cask spectrawallpicks it up
MIT — see LICENSE.