Vibresia
A free macOS app for visual worlds that move with your music. It listens to whatever your Mac is playing and turns it into live scenes, from neon waveforms to a pixel-art band that walks on stage when it hears their instrument — and you can write a world of your own, or have an AI assistant write it for you.
Website & install: https://vibresia.com
Or ask an AI assistant: "install vibresia from https://vibresia.com".
If you enjoy it, you can buy me a coffee.
Found a bug or have an idea? File an issue. Pull requests are very welcome, especially new vibescapes: see CONTRIBUTING.md.
Repository layout
Developing
Custom vibescapes and the local MCP server
A vibescape is a fragment shader — one function, vec3 render(vec2 p, float t) —
and the app supplies the uniforms, the helpers and everything else.
Vibresia ships a local MCP server, so an AI
agent can write a vibescape, check it compiles, look at the picture and try again
without anybody copying code by hand:
It's the same binary run with --mcp, speaking JSON-RPC over stdin — no
window, no port, no analytics. It reads and writes the same files the app
does, so a vibescape an agent writes shows up in the app within a second. See
VisualiserMac/README.md
for the tools it offers.
Version numbers
The build version is the date it was built, so there's no number to bump:
MAJOR counts years since 2026, MINOR is the month, and PATCH is the day of the month followed by the build number of that day — 0.10.201 is the 20th of October 2026, the first build of that day.
VisualiserMac/version.sh computes it, and build-app.sh bakes it into the app. release.sh records it in VisualiserMac/VERSION and the landing page before tagging the release, so the app, the site and the git tag always agree.
Landing page analytics
site/analytics.js is a ~180-line PostHog client — no SDK, no session replay,
no cookies — reporting into the same PostHog project as the app, separated by a
surface property (web vs mac_app).
Events: $pageview, demo_played, vibescape_card_viewed (which vibescape cards got
scrolled to), download_clicked, install_command_copied (ask-ai or curl —
the interesting split, since they are different audiences), coffee_clicked
(by placement) and outbound_clicked.
To see them without touching production, point the meta tag at a local sink:
The footer has a one-click opt-out, and Do Not Track is honoured: both stop the
page sending anything at all.
Download notifications (Discord)
Every download goes through /download on the Worker, which 302s to the latest
GitHub release and posts an embed to Discord. Server-side rather than from the
page, for two reasons: a webhook URL in the HTML would be public, and a click
that never completes isn't a download.
The embed carries source, city/country (from Cloudflare's geo), referrer,
browser, and whether that browser has downloaded before.
Errors from the app. The app can't hold the webhook (it's in the app
bundle, where anyone can read it), so it posts to POST /api/notify on this
site and the Worker forwards it. Set the shared token as a second secret, then
bake the same value into the app:
Three things arrive: audio_error (the audio source failed or was cut off —
the failure a stack trace will never show you), source_failure and
app_crash (with the top frames). A denied permission prompt is a choice, not
a fault, so it isn't reported.
The two audio reports are not behind the in-app toggle: the user asked
for that source explicitly, and a dead input is a bug nothing else will show.
app_crash is: it goes out with the crash upload to PostHog, which waits for
"Share anonymous usage" to be on, so switching that off stops both. What's
sent is app state — vibescape, genre, error text — never audio or track
names. Errors get their own cooldown (one a minute, 60/hour) so a broken
install can't flood the channel, and DISCORD_ERRORS_ENABLED = "false" turns
them off without touching download alerts.
Locally, .dev.vars (gitignored) points the webhook at a stub:
Notes:
- The webhook URL and the notify token are both credentials.
scripts/check-no-webhook-leak.sh fails if either appears in the tree — delphina had the same problem and it ended up
baked into dozens of release tags. If it ever leaks, recreate the webhook in
Discord and put the new one back with wrangler secret put.
- Bots and link previewers are filtered out, a double-clicked button only posts
once (5s debounce, 30/hour), and the post goes out via
ctx.waitUntil with a
timeout and one retry — so Discord being down can't slow down or break a
download.
- Switch it off by setting
DISCORD_NOTIFICATIONS_ENABLED to "false" in
wrangler.toml and redeploying; the site keeps working either way.
install.sh still downloads straight from GitHub, so script installs don't
produce notifications.
Shared vibescapes
A custom vibescape — one somebody wrote — used to travel as its payload in
the link: vibresia.com/share/?d=<base64 of the deflated shader>. That
works with no backend at all, which is why it was built that way. But the
link was around 370 characters, and the server answered it with a 307 to
add the trailing slash, which WhatsApp's link-preview crawler did not
follow — so the preview was blank and the link looked broken in a chat.
Now the payload is stored and the link is short:
- The same vibescape always gets the same link. A share used to be made
fresh every time — a new row, a new id, a new URL — so sharing the same
vibescape twice gave you two links, and re-sharing one somebody sent you
gave you a third. The app hashes the vibescape's contents (name, author,
url, shader) and sends the hash; the Worker looks for a share with that
hash, and for one with the same payload, and returns the row it finds
rather than making another. The
key column has a partial unique index
on it, so two devices sharing at once land on one row.
- A vibescape that arrived on a link keeps the link it arrived on. The
page puts the share's id in the deep link, the app remembers it with the
vibescape, and sharing it on hands that link back: the person who sent it
to you sent you that link, and the person you send it to should get the
same one. Edit the vibescape and the hash changes, so it gets a link of
its own.
- The picture says who made it. The app stamps the Vibresia mark and the
credit onto the picture it uploads, and the built-in pictures on the site
are drawn by the same code — see
VisualiserMac/Sources/VibresiaBrand.
The gallery
vibresia.com/vibescapes lists the built-in vibescapes and the ones people
have shared, in one list, newest first.
Sharing a vibescape has always made a link anybody can open. The gallery is
different: it lists them, so anything in it is findable by anybody at all —
and the name and the author on it are public. The share sheet says so, and
has a tickbox, on by default.
- The app asks.
POST /api/share takes public, and the share sheet
has a tickbox next to the name.
- The Worker stores it in an
is_public column and serves the list at
/vibescapes (the page) and /api/gallery (the same thing as JSON). The
cards are built on the server, so the page works with no JavaScript and a
crawler sees the list.
- The built-ins are in it too, dated by the day they first appeared —
the commit that first mentioned the vibescape — so they can be sorted
against the shares people have made.
scripts/generate-share-pages.py
works that date out and writes it into the share manifest. Their cards
use the clean still, the same as everybody else's, rather than the framed
share graphic.
- The shares that were made before the gallery existed are not in it.
They were never offered the choice, and publishing them without asking
would put somebody else's name on the front page of the site. Sharing one
again with the tickbox on puts it in.
The admin
vibresia.com/admin — unlinked, and not in any sitemap. It asks for a
token and then lists every share, the ones in the gallery and the ones
hidden, with three things you can do to each:
- Hide takes it out of the gallery and leaves the link working. The right
one for a share that is fine but should not be listed.
- Show puts it back.
- Delete takes the row, and the link with it: the picture and the name
go too. It asks first, because it cannot be undone.
The token is a Worker secret called ADMIN_TOKEN, and it is not the share
token — that one ships inside the app, so anybody can read it out of the
bundle. This one never leaves the server, and it is the only thing between
the public internet and the gallery. The page keeps it in sessionStorage,
so it lives in that tab and not anywhere else.
- D1, not Workers KV. KV is eventually consistent — a link opened ten
seconds after it was shared could reach an edge that hadn't seen the write
and 404, which is the feature failing at exactly the moment it is used.
D1 is SQLite-backed and strongly consistent, so the link works the moment
it exists. It is free: 100,000 rows written a day, 5,000,000 read, 5 GB
stored. A share is a row with a payload and a ~120 KB picture in it.
- The page is served, not redirected to. A redirect is what the preview
crawler was not following. The Worker fetches
/share/ and rewrites the
parts that are specific to the share: the title, the description, and the
og:image that points at the stored picture. Crawlers do not run the
page's JavaScript, so that has to be right in the HTML itself.
- The picture is the vibescape. The app renders it at 1200x630, at the
same moment the share page draws its still (six seconds in, so anything
driven by time has reached its stride), so the preview in a chat is the
picture the recipient gets.
- The long link still works. It carries the payload with it, so it
needs nothing stored. The app falls back to it if the site can't be
reached — sharing is not allowed to depend on the network.
The endpoint needs a token, which is a speed bump rather than a lock: it
ships inside the app, so anyone can read it out of the bundle. What it
stops is a script creating shares to burn the daily write quota.
The order matters. The migration adds the columns the Worker looks shares
up by, and the backfill fills in the hashes for the shares that were made
before the columns existed. Run the migration first, then the backfill, then
deploy the Worker. The Worker survives a database that hasn't been migrated
— it falls back to storing every share on its own, which is what it used to
do — but the same-link behaviour needs the new columns.
SHARE_TOKEN is a credential in the same class as NOTIFY_TOKEN, so
scripts/check-no-webhook-leak.sh fails if it ends up in the tree.
The public MCP server
https://vibresia.com/mcp — a remote Model Context Protocol server, so an
agent running in a browser can write a vibescape without the app being
installed and without a GPU anywhere.
It is the same idea as the app's own local server, with the parts that need
a Mac left out: it cannot draw, so it cannot show a preview or check that a
shader compiles, and it cannot see the music playing on your Mac. What it can
do is write a vibescape, lint it, and hand back a link.
- The browser does the drawing. A shader cannot be run in a Worker, but
the share page draws the vibescape live with WebGL, so what comes back from
a tool is a link where the world is already running — and it works for
whoever opens it, with or without the app.
- Making one and sharing it are two acts.
create_vibescape makes a
share and leaves it unlisted; share_vibescape puts it in the public
gallery, which is where it belongs because that is what sharing is for.
- No authentication. Both directories offer it, and a server that wanted
an account would keep people out of the thing it exists for. The writes are
rate-limited per address and land unlisted, so the cost of abuse is bounded
and does not reach the gallery.
/.well-known/mcp.json is the discovery file the MCP registry reads,
and the way the domain proves it owns the server it claims.
scripts/check-mcp-reference.py fails the release if the two copies of the
vibescape reference — the app's and the website's — drift apart, so an
agent is not told different things depending on which one it asked.
Changelog and open issues
Both sections on the landing page read from api.github.com at runtime rather
than being baked in, so they can't go stale between deploys. Three things keep
that honest:
- Cached in
sessionStorage for 15 minutes. GitHub's unauthenticated API
allows 60 requests an hour per IP, and every visitor would be spending from
the same one.
- Lazy — the fetch starts when the section scrolls into view.
- Never fatal — if GitHub is down, rate-limiting us, or the JSON moves, the
section says so and leaves a link to the repo behind. Issue titles are
rendered through
textContent, never innerHTML, because they're whatever
someone typed into a text box.
SEO
- Submit the sitemap once in Search Console —
https://vibresia.com/sitemap.xml. Re-submit after site deploys; it also carries lastmod.
- Structured data is
SoftwareApplication + Organization JSON-LD in index.html. The version is the build's date, so there's nothing to bump by hand — VisualiserMac/version.sh --write puts it in both index.html and VisualiserMac/VERSION, and scripts/check-vibescapes.sh fails the release if they drift apart.
- Turn on "Always Use HTTPS" in the Cloudflare zone for
vibresia.com. The Worker's own http: → https: redirect covers the same ground, but the zone setting is what stops a duplicate being served at all. The old audiovane.minithlabs.com name is still attached to the Worker so it can redirect — see the note below.
- The old domain is a redirect, not a second site.
audiovane.minithlabs.com is attached to the same Worker, which answers it with a 301 to vibresia.com keeping the path — so the old install command and any old link still work. Its DNS has to keep pointing at Cloudflare for that to happen. One thing a domain change can't carry over: the analytics opt-out is in the browser's localStorage, which is per-origin, so anyone who opted out of analytics on the old site has to do it again on the new one.
- The Worker is attached with routes, not custom domains.
vibresia.com was pointed at Cloudflare before the Worker existed, so its DNS records are ones Cloudflare didn't make; a custom domain would want to own them and refuses to overwrite them. A route binds the hostname to the Worker and leaves the DNS alone. run_worker_first is on for the same reason: the redirects are decided in the Worker, and an asset served straight from the edge would skip them.
- Caching is Cloudflare's default (
max-age=0, must-revalidate), and a Cache-Control rule in site/_headers appends to that rather than replacing it, so it can't be changed that way. Repeat visits revalidate with a 304 from the ETag instead of re-downloading 5.9MB, which is the part that matters.
- Page is a single static file: one H1, 16 lazy-loaded images with descriptive alt text, no framework. Keep it that way — LCP measured ~0.08s locally and CLS ~0.005 before any of this.
Exporting a video
The share sheet can save the last ten seconds of a vibescape, with the music,
as a movie in Downloads.
The picture is the app's own renderer — the same draw the window is doing,
over the last ten seconds of its clock — so it is the vibescape rather than
a picture of it. The sound is the last ten seconds of what the app was
hearing, which is the music as it was.
The clip is as long as the audio the app has: ten seconds of music if there
has been ten seconds of music, and what there is if there hasn't. The app
has to be listening to something for there to be any.
The song, and its genre
The Band's genre comes from the song when the app can work out what is
playing. Two ways, and they sit side by side:
- By name. Apple Music and Spotify both post a notification when the
track changes, with the title and the artist in it. The app looks the song
up in Apple's public catalogue, which knows its genre — so the stage is
chosen for the track rather than guessed at from the waveform.
VisualiserMac/Sources/VisualiserMac/Audio/SongIdentifier.swift.
- By listening. ShazamKit matches a signature of the sound against
Shazam's catalogue, which works with anything that makes a sound — a
browser, a film — rather than only with the players that post
notifications.
VisualiserMac/Sources/VisualiserMac/Audio/ShazamIdentifier.swift.
The first one is what ships. The second one is behind a setting in the app
(Identify by listening, in the About panel), because matching against
Shazam's catalogue needs the app to be allowed to ask, and being allowed is
decided by the ShazamKit service on the app's identifier — and because it
sends a fingerprint of the sound to Apple, which is the person's decision to
make rather than ours.
What ShazamKit needs, and why it isn't on by default
Apple issues the com.apple.developer.shazamkit entitlement for apps signed
with a provisioning profile. This app is signed with a Developer ID
certificate and notarized, so the entitlement has to be declared in the
signature by hand — and declaring one the team hasn't been granted is not a
build that fails to match, it is a build that will not launch. macOS kills
the process:
The same app without the entitlement runs, and the match comes back
com.apple.ShazamCore error 102 — "not allowed", not "not found".
So the entitlement is opt-in (SHAZAMKIT=1) and only worth turning on once
Apple grants it for a Developer ID signature. There is no switch in the
portal for it: it has to be requested, and the request is written out in
docs/shazamkit-entitlement-request.md.
The feature itself ships now and behaves properly in the meantime. With the
setting on and the entitlement missing, the app asks once, is told "not
allowed", switches the feature off and says why — it does not keep asking,
and it does not fall over. The day the entitlement is granted, a rebuild
with SHAZAMKIT=1 is all that is needed.
The spike is still here, because that question deserves an answer rather
than a forum post:
What leaves the machine
The audio never does — it is analysed locally and drawn locally, and the
band's stage is drawn from what the shader does with it.
The name of the song does, when a music app says what it is playing: the
title and the artist are looked up in Apple's public catalogue, which is the
same search the iTunes Store does. Nothing else is sent, and no account is
involved. The catalogue's answer is a genre, and the genre is a local
decision from then on.
The lookup needs both the title and the artist to match. The catalogue will
happily answer "Me and Your Mama" for "Redbone" — same artist, different
song — and a wrong song means a wrong genre on the stage, so a result that
isn't clearly the right song is thrown away rather than used. When the
catalogue doesn't know the song, the app falls back to the audio heuristic
it has always used, and nothing is lost.
The one thing that can leave the machine is a fingerprint, and only if
the person turns on Identify by listening: a one-way signature of about
four seconds of sound, made by Apple's own framework, which cannot be
turned back into audio. It goes to Apple's Shazam service to name the
track, and the app never sees it. It is off until it is switched on, and
switching it off stops it — see
the privacy page for how it is described there.
To try the lookup without a music app in the picture:
Tuning The Band
VisualiserMac/Tests/band-fixtures.yaml lists YouTube videos labelled with their genre and who's playing. scripts/band-eval.py plays each one in Chrome while Vibresia listens, then prints a scorecard (genre right? right musicians on stage?) and saves the detector's features and per-genre scores to VisualiserMac/Tests/eval-results/ for tuning. Vibresia needs system audio permission first; keep other audio off while it runs.
Releasing
- Add any new vibescape to
site/index.html (a card with data-vibescape="<case name>").
VisualiserMac/release.sh runs both site checks, decides the version from the date, records it in VisualiserMac/VERSION and the landing page, builds, zips Vibresia.app and publishes GitHub release v<VERSION>. The installer and the app's update check always use the latest release. If a Developer ID certificate is in the keychain, the release is notarized as part of this — the ticket is stapled onto the app between the build and the zip, because signing the bundle again would invalidate it. See Signing and notarization.
npx wrangler deploy if the site or worker changed, then re-submit the sitemap. First deploy after cloning needs npx wrangler secret put DISCORD_WEBHOOK_URL (see Download notifications).