A Metal-powered music visualizer for macOS, in the spirit of the classic iTunes/Music visualizer — but tone-reactive: a 64-band log-spaced FFT drives every visual, so bass, mids, and treble each shape the picture differently. It names the track you're listening to in Music or Spotify without asking for a single permission to do it.
synesthia.app — download, screenshots, and support.
The notarized build is at synesthia.app/download — universal (Apple silicon and Intel), macOS 15 or later, and it updates itself via Sparkle. Or build it from source.
- First launch opens a welcome sheet explaining each source and what it costs. Continue grants nothing and plays a bundled demo loop, so you can see it work before deciding. Pause it from the source menu, the play/pause button, or the space bar.
- Pick System audio to visualize what you're actually listening to. macOS asks for Screen & System Audio Recording — the only sanctioned way to hear system output (audio only; the video leg is 2×2 px and discarded). If you grant it after the first attempt, the app retries on its own.
- Start playing something in Music or Spotify and the now-playing badge fills in by itself — title, artist, album, and the player's icon. This needs no permission of any kind; the players broadcast it.
- Optional: Control Music… / Control Spotify… in the source menu adds play/pause/skip buttons and real album artwork. That one does ask for Automation, only when you click it, and only in the direct-download build.
- Shortcuts:
Spaceplay/pause ·⌘1–⌘4switch visualizer ·⌘→/←next/previous track ·⌘Lstart/stop listening ·⌘Oopen an audio file · green button /⌃⌘Ffullscreen.
| Source | What it does | Metadata shown |
|---|---|---|
| System audio | Anything the Mac plays — Music, Spotify, a browser, a game | Title, artist, album, player icon |
| Audio input | Mic, line-in, or any input device (picker in menu) | Device name |
| Audio file | Plays a local file in-app, loops it | Filename |
There is no separate "Music app" source any more, and that is an improvement rather than a removal: it was never a different way of hearing anything (Music has no audio stream to tap, so it always went through the same system-audio capture) and the metadata it used to be needed for now works for every recognized player. Now-playing is a layer on top of System audio, not a mode you have to switch into.
Recognized players today are Music and Spotify. Adding another is one
table row in NowPlayingObserver.swift — see
docs/macos-integration.md.
| Name | Idea | Options |
|---|---|---|
| Nebula | ~100k GPU-simulated particles orbiting a bass core, galaxy disc, and comet halo | Density, Trails, Turbulence |
| Spectrum Tunnel | Flight through a tube whose angular slices are the live spectrum | Twist, Glow |
| Aurora | Layered ribbons riding the waveform, each glowing with its slice of the spectrum | Ribbons, Wave height |
| Bars | A studio console: LED spectrum wall with peak holds, analog VU meters, and a desk of moving faders | Columns, Segments, Desk |
All visualizers share global Sensitivity, Speed, and five color palettes (Prism, Ember, Ocean, Violet, Mono) — see the slider icon in the control bar. Normalize Loudness (on by default, in Settings — ⌘,) slowly adapts the analysis to how loud the source actually is, so a quiet mic and loud mastered music read the same without retuning Sensitivity. Settings persist across launches.
- Visuals don't react — check System Settings › Privacy & Security › Screen & System Audio Recording, then click play again. If they react but weakly, give Normalize Loudness a few seconds to adapt (or check it's on in Settings, ⌘,), or raise Sensitivity in the options popover.
- No track info — the source must be System audio, and the badge only appears once the player changes something: players broadcast their state on a transition, not on request, so skip a track or hit pause and it will fill in. Only Music and Spotify are recognized.
- No album artwork — cover art isn't in what the players broadcast, so the badge draws a palette-derived tile with the player's icon instead. Real artwork needs Control Music… (Automation permission, direct-download build, Music only — Spotify offers no local artwork).
Everything below is either bundled with macOS/Xcode or one command away. There is no dependency you have to hunt down.
| Why | How | |
|---|---|---|
| macOS 15 (Sequoia) or later | the deployment target is 15.0 | — |
| Xcode 26.6+ | Swift 6.3 toolchain, SDKROOT = macosx |
Mac App Store, or brew install --cask xcodes |
| Command line tools | xcodebuild, codesign, hdiutil, sips, afconvert |
xcode-select --install |
| Metal Toolchain | compiles Shaders.metal at build time |
xcodebuild -downloadComponent MetalToolchain |
| Node ≥ 22.12 | the website and the Cloudflare tooling | brew install node |
| Python 3 | make demo-track, make check-metadata |
ships with macOS; stdlib only, no pip installs |
Sparkle's command line tools (generate_appcast, sign_update) are not
installed separately — they arrive with the Swift Package Manager artifact the
moment you build the Synesthia Direct target, and the scripts find them there.
Only if you are cutting a release you also need an Apple Developer Program
membership, the certificates in docs/distribution.md,
and npx wrangler login for the Cloudflare side.
git clone git@github.com:jonjaques/synesthia.git && cd synesthia
make install # npm deps for the root: Prettier
make build # Debug build of the App Store target
make test # 19 Swift Testing cases over AudioAnalyzerThe website is a separate project with its own lockfile — cd web && npm ci,
then npm run dev. See web/README.md.
Xcode works normally too — open Synesthia.xcodeproj and pick a scheme.
Any .swift file you add anywhere under Synesthia/ is compiled
automatically; the project uses a synchronized file group, so never add file
references by hand.
make run # build, then launch the app
make build-direct # the Sparkle-enabled build, for updater work
make lint # check formatting
make format # fix what lint complains about
make healthcheck # run this before pushing: lint + test + build-directmake lint is formatting only: Prettier across everything outside web/, then
swift format lint --strict over the app, the tests and scripts/shotkit.swift
— under --strict any diagnostic, down to indentation, is an error. make format fixes both in place. swift-format ships inside the Xcode toolchain, so
there is nothing to install, and Xcode's own Editor ▸ Structure ▸ Format File
with swift-format reads the same .swift-format at the repo root. On top of
formatting, the build is a gate too: every configuration is expected to compile
with zero warnings.
make healthcheck is the whole PR gate in one target — lint, the test suite,
then the Direct build — and is exactly what CI runs.
.github/workflows/healthcheck.yml runs on
every pull request push, as two independent jobs matching the two projects in
the repo:
| Job | Runner | What it does |
|---|---|---|
web |
ubuntu-latest |
Everything from inside web/: npm ci, check, typecheck, build |
apple |
macos-26 |
make install && make healthcheck |
The runner has no signing identity, so the Makefile ad-hoc signs whenever CI
is set — enough for the sandbox, and the real releases are signed by
scripts/build-direct.sh on a machine that has the certificates.
make on its own lists them. Each is a thin wrapper around xcodebuild or a
script in scripts/, and every script stays runnable directly with --help.
| Target | What it does |
|---|---|
build |
Build the App Store target. CONFIGURATION=Debug by default |
build-direct |
Build Synesthia Direct — the only target that links Sparkle |
run |
Build, then open the resulting .app |
test |
The SynesthiaTests Swift Testing suite |
clean |
xcodebuild clean plus rm -rf build/ |
app-path |
Print where the built .app actually landed for this CONFIGURATION |
install |
npm install at the root — Prettier, nothing else |
healthcheck |
The PR gate: lint, test, build-direct |
lint · format |
Check / fix formatting: Prettier, then swift-format |
demo-track |
Regenerate the bundled 32 s demo loop, deterministically |
screenshots |
Drive the real UI and capture every visualizer (see below) |
check-metadata |
Check the App Store listing drafts against Apple's field limits |
bump |
Raise the version everywhere it is recorded — see Releasing |
direct · direct-fast |
Notarized direct-download build; -fast skips the notary round trip |
appstore · appstore-upload |
Archive and validate the store build, optionally upload it |
sparkle-keys |
One-time: create the EdDSA update-signing key |
appcast |
Regenerate the signed Sparkle feed into build/releases |
publish-release · publish-dry-run |
Upload the release to R2, or show what would go |
Variables: CONFIGURATION (Debug default, then Direct and Release —
docs/distribution.md explains why the store and direct
builds differ), BUMP for make bump, DESTINATION for make test,
and ARGS to forward flags to the wrapped script, e.g.
make screenshots ARGS="--only nebula".
make screenshots relaunches the app once per registered visualizer with
-visualizerID in its argument domain, sizes the window, captures it, then
repeats in fullscreen. Each run writes under its own prefix
(20260725-174312Z-nebula-windowed.png) so takes accumulate rather than
overwrite; ARGS="--prefix hero" names one yourself, --prefix '' drops it.
It assumes Music or Spotify is already playing so the
now-playing badge and artwork are populated (it passes
-playerControlEnabled YES to seed the state at launch, since players only
broadcast on a transition). Because it drives another app's
window, the terminal you run it from needs two grants in System Settings ›
Privacy & Security: Accessibility (resize the window, move the pointer so
the auto-hiding chrome reappears) and Screen & System Audio Recording
(screencapture itself). The script checks both up front and tells you what's
missing. ./scripts/take-screenshots.sh --help lists the options.
Synesthia ships through two independent channels, built from two targets over
three configurations. The short version: Synesthia Direct / Direct is the
notarized download from synesthia.app and is the only one with Sparkle and the
Apple Events layer (transport control and cover art); Synesthia / Release
is the Mac App Store build and contains neither. Both show the now-playing
badge, which needs no permission at all. docs/distribution.md has the full
rationale; docs/app-store-launch-plan.md tracks
what is still outstanding.
make bump # patch: 1.0 -> 1.0.1
make bump BUMP=minor # 1.0.1 -> 1.1
make bump BUMP=major # 1.1 -> 2.0
make bump BUMP=2.5 # or set it explicitlyThe version lives in project.pbxproj as build settings, duplicated across
every target and configuration — nine copies of each. make bump writes
them all, always increments the build number, refuses to run if they have
drifted apart, and updates the website's RELEASE.version too.
Always let the marketing version move, not just the build number. The DMG is named
Synesthia-<version>.dmg, so two releases sharing a version collide on one filename in R2.publish-releasehard-fails on that rather than silently serving old bytes under a new signature.
make test # nothing ships that hasn't passed
make direct # archive, sign, notarize app + DMG, staple, verify
make appcast # add it to the signed Sparkle feed
make publish-dry-run # confirm what is about to be uploaded
make publish-release # DMGs first, then latest.json, then the appcastOrder is not cosmetic. make direct notarizes twice — the app before the disk
image is built, so the copy users drag out of the DMG carries its own ticket,
then the signed DMG itself. publish-release uploads artifacts before the
appcast, because the appcast is the announcement: the moment it lists a
version, installed copies start fetching that URL.
Afterwards, update RELEASE.size in web/src/consts.ts from the byte count
make direct printed, then commit and push — Cloudflare Pages deploys the site
from git, while the release artifacts themselves live in R2 and need no rebuild.
make check-metadata # listing copy against Apple's field limits
make appstore # archive, assert, export
make appstore-upload # …and send it to App Store Connectmake appstore refuses to continue unless the archive is universal, carries no
get-task-allow, no Apple Events entitlement, no tell application "…"
string for any player, and no Sparkle — the store build must not ship an updater. Flip
APP_STORE_AVAILABLE in web/src/consts.ts once review actually passes.
Developer documentation lives in
docs/— architecture, the audio pipeline, rendering, the visualizer plugin system, and macOS integration, written for developers new to audio/graphics/macOS.
SystemAudioCapture (ScreenCaptureKit) ─┐
InputDeviceCapture (AVAudioEngine tap) ─┼─▶ AudioAnalyzer ──▶ AudioSnapshot ──▶ MetalVisualizerView ──▶ active Visualizer
FilePlayer (AVAudioEngine + tap) ─┘ (vDSP FFT) (lock-guarded) (MTKView, 60 fps) (Metal pipelines)
NowPlayingObserver (distributed notifications, no permission) ──▶ title/artist/album/state
PlayerRemote (Apple Events, opt-in, direct build) ──▶ transport + cover art
AudioAnalyzeringests mono samples from any audio thread, runs a Hann-windowed 2048-point FFT (Accelerate/vDSP), and publishes a snapshot: 64 log-spaced bands (30 Hz–16 kHz), a 256-sample waveform, bass/mid/treble/level scalars, and a beat envelope from bass-transient detection. Visuals decay gracefully when the source goes silent.- The render loop pulls the latest snapshot each frame — no audio→UI publishing, no allocation churn on the audio thread.
Visualizers are plugins (Synesthia/Visualizers/VisualizerCore.swift):
- Conform a class to
Visualizerand give it a staticVisualizerDescriptor(id, display name, tagline, up to fourVisualizerOptionsliders). - Add shader functions to
Shaders.metal(compiled at build time into the app's default library). A future external bundle would instead ship MSL source and compile it at load time withMTLDevice.makeLibrary(source:). - Add the descriptor to
VisualizerRegistry.all's initial list, or callVisualizerRegistry.register(_:)at startup (the hook external bundles would use).
Declared options automatically appear in the Options popover and arrive in the
shader as the p array in VizUniforms. Audio data arrives as a 64-float band array, a
256-float waveform, plus derived scalars — see AudioAnalyzer.swift.
Each of these is assessed in docs/roadmap.md — feasibility in the current code, rough cost, and the platform research behind the ones that depend on Apple's rules. Roughly in order of promise:
- Beat-synced scene changes (auto-rotate visualizers every N bars).
- More visualizers: flocking and fluid-like sims on the compute path Nebula opened up, raymarched geometry.
- User-defined color palettes (palettes are already per-visualizer).
- True external plugins: load visualizers from
~/Library/Application Support/Synesthia/Pluginsviaregister(_:). - Screen saver target reusing the same render stack.
- String Catalog localization — UI strings are currently literals; the
project has
STRING_CATALOG_GENERATE_SYMBOLSenabled and should migrate. - Visualizer test coverage —
SynesthiaTestscoversAudioAnalyzerthoroughly; the Metal visualizers have none and are much harder to assert on.
MIT © 2026 Jon Jaques.
The MIT grant covers the source. Two things in this repository are not code and
are not licensed with it: the app icon (Synesthia/Icon.icon, assets/Icon Exports/) and the Synesthia name and wordmark, which identify the shipped
app. Fork the code freely — just ship it under your own name and mark so nobody
mistakes your build for this one.
Sparkle, the only third-party dependency, is separately MIT licensed and linked into the direct-download target only.
- Website — synesthia.app
- Source — github.com/jonjaques/synesthia
- Bugs and feature requests — GitHub Issues
- Support — synesthia.app/support
- Privacy — synesthia.app/privacy. The app
collects nothing; see
PrivacyInfo.xcprivacy.