MilkDrop-class music visualizer that renders real GPU pixels inside your Ghostty/Kitty pane. Play anything in Music.app — or Spotify, or a browser tab — and the pane becomes a beat-synced light show: ray-marched tunnel, hyperspace starfield, CQT waterfall, feedback-warp plasma. 60fps, true pixels, no Unicode bars, no half-block ASCII.
cargo run --release
A dedicated top line shows what's playing — ♪ Title — Artist ▕████░░▏ 1:29 / 3:35 · Music —
with a live progress bar; the playhead is polled from Music.app/Spotify every ~2s
via Apple Events (public scripting API) and extrapolated between polls. Track
progress is also fed to scenes as u.track_progress (0..1). Disable with
--no-track to reclaim the row for pixels.
Music.app / Spotify / browser
│ Core Audio process tap (global stereo mix — works with DRM'd
│ Apple Music because we tap the process output, never the files)
│ + Apple Events now-playing poll (title/artist/duration/position
│ from Music.app and Spotify, extrapolated between polls)
▼
DSP @ render cadence — 2048-pt FFT, 128 log bands, bass/mid/treble
envelopes, spectral-flux onsets, BPM → lock-free ring
▼
wgpu (Metal), offscreen — WGSL scenes with audio uniforms, a 128×512
spectrum-history texture, and previous-frame feedback (ping-pong)
▼
POSIX shared memory → kitty graphics protocol
auto-probed per terminal (functional probes, not `a=q` queries):
· frame mode: root image once (a=T,t=s), then in-place frame
updates (a=f,r=1,t=s) — Kitty, Ghostty 1.4+
· retransmit mode: delete-all + fresh a=T per frame (mpv-style)
— Ghostty 1.3.x, still shm-only
▼
Ghostty rebinds the texture and composites it in your pane
The PTY only ever carries ~100-byte escape sequences per frame — pixels travel through shm (t=s), never base64. The status line shows the measured PTY byte rate so you can verify (typically single-digit KB/s at 60fps; a base64 hot path would be ~300 MB/s).
- macOS 14.2+ (process-tap API), 15+ preferred. Apple Silicon recommended.
- Ghostty (first-class) or Kitty (second-class). Nothing else:
cymatic needs the kitty graphics protocol with POSIX shm transport.
Terminal.app, iTerm2, and Alacritty are detected and refused with one line.
- Ghostty 1.4+/tip and Kitty get in-place
a=fframe updates. - Ghostty 1.3.x has no
a=f(and known non-direct-medium quirks); cymatic auto-falls back to mpv-stylea=Tretransmit — still shm, never base64. Runcymatic doctorto see exactly what your terminal answered.
- Ghostty 1.4+/tip and Kitty get in-place
- No multiplexers — not tmux, not herdr. A mux
puts its own virtual terminal between cymatic and Ghostty: herdr's embedded
libghostty-vt allows only the direct graphics medium (it answers
t=swithEINVAL: unsupported medium), doesn't report pixel geometry, and never composites pixels into the host terminal; tmux needs unicode-placeholder routing that v0 doesn't implement. cymatic detects both and refuses with one line. Run it in a plain Ghostty/Kitty tab. - Local only — shm is by definition same-machine. SSH is out of scope.
First run triggers macOS's one-time System Audio Recording prompt, attributed to your terminal (Ghostty). Grant it once. While the tap runs you'll see the purple recording indicator in the menu bar — that's the OS being honest, not a bug. No virtual audio driver, no BlackHole, no multi-output device: the tap is the capture path.
If you denied the prompt: System Settings → Privacy & Security → Screen & System Audio Recording → System Audio Recording Only → enable your terminal.
The now-playing line additionally triggers a one-time Automation prompt
("Ghostty wants to control Music") the first time metadata is polled. Denying
it only hides track metadata; the visualizer keeps running. cymatic track-check tests this path standalone.
| command | what |
|---|---|
cymatic |
run the visualizer (tap audio, 60fps target) |
cymatic --audio fake |
built-in 128-BPM techno generator (no permission needed) |
cymatic --scene tunnel |
start on a specific scene |
cymatic --present retransmit |
force the mpv-style presenter (auto-probed by default) |
cymatic --no-track |
hide the now-playing line (no Apple Events) |
cymatic doctor |
capability report: terminal, gfx+shm probe, Metal, geometry |
cymatic tap-check |
prove the tap works: live RMS/bands as text |
cymatic track-check |
prove metadata works: title/artist/position polls |
cymatic bench |
headless GPU benchmark (--width --height --frames) |
| key | action |
|---|---|
Tab / Shift-Tab |
next / previous scene |
c |
cycle color mode (album → curated → drift → duotone) |
1–6 |
jump to scene |
r |
reload current scene from disk |
q / Ctrl-C |
quit clean (delete image, unlink shm, release tap, restore terminal) |
- tunnel — ray-marched twisting tube; the live spectrum sculpts the walls by angle, beats kick the camera forward.
- starfield — deep-space ambient: domain-warped nebula breathing with the mids under a parallax star warp.
- waterfall — CQT-style scrolling spectrogram from the GPU history texture, palette-graded, with a song-progress marker.
- plasma — smooth MilkDrop feedback warp: previous frame swirled and re-injected with kaleidoscopic ink.
- ink — organic fluid: curl-noise advection with palette ink breathed in by wandering emitters; beats exhale puffs.
- horizon — synthwave: slatted sun over a scrolling perspective grid, spectrum glowing along the horizon.
All scenes are groove-locked: beats and energy drive motion (camera kicks,
warp speed, flow), never full-screen luminance steps. --intensity (0..1)
scales what flash/glow remains.
Four selectable color sources feed the same 4-stop palette every scene reads
(c cycles at runtime, --color picks the start):
| mode | source |
|---|---|
album (default) |
dominant colors k-means'd from the current track's cover art (Music.app artwork / Spotify artwork URL); crossfades ~0.8s on track change; falls back to curated |
curated |
hand-picked palette per scene |
drift |
slow continuous hue rotation |
duotone |
one scene accent on near-black |
Scenes are plain WGSL files in scenes/ (or $CYMATIC_SCENES), live-reloaded
on save — edit while the music plays. Each gets a prelude with audio uniforms
(u.bass, u.beat, u.bpm, …), spectrum(x) / spectrum_at(x, age)
sampling a 128-band × 512-row history texture, and prev(uv) for feedback.
A broken shader keeps the last working pipeline and shows the error in the
status line.
Resizing the pane rebuilds the framebuffer at the new pixel geometry
(queried via CSI 16 t) — 1:1 pixels, no stretching.
Headless bench on Apple Silicon, full render + GPU→CPU readback:
| scene | 1280×720 | 2560×1440 | 3456×2000 |
|---|---|---|---|
| tunnel | 732 fps | — | 125 fps |
| starfield | 1229 fps | — | — |
| waterfall | 1343 fps | — | — |
| plasma | 1371 fps | 676 fps | — |
The v0 bar is ≥45 fps at 1280×720; there's more than an order of magnitude of headroom before the presenter, not the GPU, becomes the story.
- Terminal bar charts of any kind. If the output looks like
cava, it's a bug. - BlackHole / loopback drivers as the capture path.
- tmux, SSH, Terminal.app/iTerm/Alacritty.
- Windows/Linux (wgpu keeps the door open; the presenter and tap are macOS/kitty-specific).
- Being a music player. Playback stays in Music.app.
- libprojectM as scene backend #5 — MilkDrop presets rendered to FBO.
- Per-process taps (visualize only Music.app, bundle-id filter).
- Dirty-rect presenting for partial-update scenes (the
a=f,x=,y=path exists in the encoder).
MIT