Explore the sharing and collaboration workflows, with availability and account requirements described for each service.
What it is#
Every song is a URL. The share button packs the whole song — source, name, and any knobs you've moved off their compiled defaults — into a compressed URL fragment and copies it to your clipboard. Nothing is uploaded: the fragment never reaches a server, and whoever opens the link compiles and plays it in their own browser (the determinism + sandbox bet of Share music and collaborate §0). A song too big to fit a URL is the cue to use a paste.
The embed player (embed.html) is the same link, played in place: a self-contained card — play/pause, title, an output oscilloscope, and an "open in workbench" link — for <iframe>-ing into a blog or chat unfurl.
Stage 1 also shipped a video export; it was deleted with the TypeScript workbench, 2026-08-09.
Pastes (Stage 2) are the backend permalink: the publish button POSTs a song to the web/ Worker and gets back an immutable /s/<id> URL. The server-rendered song page plays the song (it embeds the Stage 1 player), shows the source, threads remix lineage ("remixed from X · N remixes"), and offers Remix (→ workbench, recording parent) and Copy link. Publishing also renders an og-card (title + wavescape) client-side and uploads it, so the link unfurls with an image on socials/chat.
Volta radio (radio.html) and weekly challenges (/c/:slug) are the Stage 7-lite programs. Radio autoplays a feed of pastes in sequence (each compiled + rendered client-side); a challenge is a brief + a starter paste whose remix tree is the entry gallery.
Sign in with GitHub or Discord (Stage 4) gives published songs an owner: a sign in header button offers the providers, and from then on publishes are owned, your profile lives at /@you, and you can like others' songs. The session is a Bearer token in localStorage — no password, no cookie.
Your @handle is claimed once, at your first sign-in, and never changes — it is what pack specs (@you/name) and profile URLs are built from. You can link the other provider to the same account afterwards (and unlink it again, as long as one remains); the handle is unaffected either way. If the handle you'd have got is already taken by somebody who signed in first, you are given a suffixed one (@you-discord) and told so.
The registry (Stage 3) is the module commons: the pack header button publishes the current file as @you/name — an entry module, optionally with sibling modules (@you/name/tone) and small samples (@you/name:bd), all versioned together; packs compose by importing other packs — and any song can then import "@you/name" (…) (latest, locked by volta.lock on first resolve) or import "@you/name@3" (…) (pinned). Every pack carries a license (CC0-1.0, MIT, CC-BY-4.0, CC-BY-SA-4.0 or custom:<text>), which covers its samples too. The workbench spots registry specs as you type, fetches them in the background, and recompiles; volta-cli resolves the same specs with a ~/.volta/registry offline cache. Versions are immutable integers, content-addressed by sha-256; imported pack macros arrive pre-namespaced (@you/name.cutoff) by Macro Namespacing. Browse at /packs; each pack has a page at /p/@you/name.
Live rooms (Stage 5, first slice) turn a session into a broadcast. The room header button opens a room and copies a /r/<id> link; anyone who opens it hears the performance — the host's song source is relayed to their browser and rendered there (§0, no audio streaming). As the host edits or changes a scene, listeners follow. The relay is one Cloudflare Durable Object per room (the first stateful backend component): it carries presence (a live roster), the host's song snapshot, and transport (scene/play/stop) over WebSockets, hibernating when idle. Listeners are read-only audience.
Signed-in users can also co-edit (Stage 5, second slice): the editor buffer is shared over a Yjs CRDT, so several people type into one document — each compiling and playing locally off the converged source, with remote cursors showing who is where. The relay stays dumb for this: it ferries opaque Yjs sync/awareness blobs between editors, so the convergence + late-join handshake run entirely client-side (no yjs in the Worker).
Since 2026-08-05 it is one room, not two. Broadcasting and co-editing are the same room, and your role comes from your token: anonymous peers listen, signed-in peers co-edit, and one peer additionally holds the host seat (the tempo master that publishes transport + the clock origin). There are three words for those three roles and no others (2026-09-21): host (plays the song; everyone follows its transport), editor (can type in the room's song) and listener (hears the room, cannot type). The pre-join form, the joiner's own header and the host's peer list use the same three — "audience" remains the WIRE label (role=audience) and stays on the wire. Listeners are still fed a materialized snapshot rather than the CRDT — that is what keeps the frozen room.html audience page working — and the clients elect which write-role peer publishes it (publisherId: the host if connected, else the lowest editor id), so the relay holds no leader state and a room whose host leaves keeps playing for its listeners. The host grants and revokes the editor role live, enforced in the relay — and in an owned room that grant is how anyone but the host gets a pen at all. A listener can ask for it: "Ask to edit" puts the reserved file key !ask-edit in that peer's per-file presence, the host's list reads wants to edit · [Allow], and the joiner's own header reads listener · waiting for the host to let you edit until the grant lands. It rides files because that is the ONLY broadcast the reducer accepts from a read-only peer (sync, awareness and signal are all dropped for one), so the request needed no relay change; the marker is never a document, and it withdraws itself when the pen arrives. ⌘Z in a room undoes your edit through the shared document's undo manager, never a peer's. A finished jam freezes to a paste crediting everyone who held a pen. A room co-edits the whole song — one document per module file — and the host can hand out or take back the pen per file, also enforced in the relay.
Browser Studio playback snapshots (2026-09-05): the elected publisher supplies its last accepted program and that program's frozen import closure. Collaborative text may be ahead of the audio; publishing must not combine an accepted entry with newer, unrun imports. Until a module is confirmed for the current room, no unrelated local song is published as a fallback. Acceptance queues a new snapshot. CRDT seeding/convergence remains independent, and live macro/fader overrides are not included. Other room clients without this optional audio-host seam retain their existing document-snapshot behavior. The browser shares one install/control queue across local and room operations; failed room compilation/installation cannot start the previous graph as the new room song. See Mixing tracks and buses §5.1 for the receipt boundaries.
Voice chat (Stage 6, 2026-08-05) puts the conversation in the same tab as the jam. Peers who hold a pen can join a WebRTC mesh: push-to-talk (or a latch), a hard mute, a mic meter, and a speaking dot beside each talker in the roster. While someone is talking the music ducks under them. Only the handshake goes through the relay — the audio is peer-to-peer, and the relay never learns WebRTC any more than it learned Yjs. Mesh voice is browser-only: volta-studio shows who is in the call and says voice is browser-only for now. See "Voice chat" below.
The audience hears it too (Stage 6.5, 2026-08-06). A mesh cannot fan out to an unbounded audience — so it does not try. One elected talker mixes the call it is already receiving, encodes it once with WebCodecs, and the relay fans that single stream out to the listeners: upload stays O(1) in listeners, the mesh is untouched, and no SFU is deployed. A listener sees "voice · listening (N talking)" with its own mute and volume, and its music ducks under the chat. Voice for listeners is capped per room (24) because unlike the music it is real bytes; the music is uncapped, because everyone renders it locally. See "Audience voice" below.
Rooms work in both workbenches, and either can host as well as join: the desktop shell (volta-studio) and the browser workbench (the same Rust UI in wasm). (A third client, the legacy TypeScript workbench, was deleted 2026-08-09; the single-buffer "legacy-shaped" peer behavior it pinned still matters for room.html and older clients.) All three surviving clients speak one wire contract, so a jam can mix them: the desktop and the browser converge on the same document, and a listener on any of them beat-locks to the same published origin.
Surface#
- Share links —
#s=<blob>fragments are still built (buildShareUrl, by the embed player, radio and the audience view) and still read on boot. The header share button itself went with the TypeScript workbench, 2026-08-09; publishing a paste is the workbench's share gesture now. - Opening a
#s=link — drops the song into the editor as a real document, compiled and ready, taking precedence over the local autosave. A payload's knob positions are not seated: there is no macro-seeding seam in the wasm shell yet, so the page logs the degradation and opens at the compiled defaults (runtime/public/studio-host.js). - Embed —
…/embed.html#s=<blob>renders the same song as a minimal player; "open in workbench →" links back to the full editor on the same fragment. - Publish — File ▸ Publish to Community…; hidden unless a paste backend is configured (
VITE_PASTE_API, or a same-origin/apiin production). Publishes the song and copies the/s/<id>permalink. - Song page —
GET /s/:id(served by the Worker): the embedded player, source, lineage, Remix + Copy link. Under the title it says what the song is in the song's own words — the leading--comment block — and the tempo and key it declares (tempo = 128bpm,key = "c minor"), read out of the source byweb/src/song-facts.ts, because a paste row stores neither. A remix says "remix of <title> by @handle", linking both. Counts read as English ("1 view · 2 plays"), throughcount/pluralinweb/src/html.ts. Remix opens the workbench at?remixOf=<id>#s=…— the song rides in the fragment, so it works before any API call — and past 8 KB of fragment the link hands over?paste=<id>&remixOf=<id>instead, so a big song still has a link a person can paste into a chat. Publishing from there recordsparenteither way. Emitsog:image(→/og/:id.png) once a card exists;og:descriptionis the song's own description when it has one. - og-card —
GET /og/:id.pngserves the link-unfurl image (R2, immutable cache);POST /api/paste/:id/card(image/png) uploads it at publish. - Radio —
radio.html: play/pause, prev/next, recent/trending toggle, a clickable queue, an output scope, and per-track song-page + workbench links. Each track auto-advances after ~45 s. With auto-DJ on (the default toggle; see Continuous mixes with Auto-DJ) the queue is reordered harmonically on the Camelot wheel (BPM/key read textually from each paste, exactly from the compiled module at the seam), the next song is compiled ahead on a secondSongPlayerdeck, and the handoff is a musical seam: tempos within ±8% are stepped to the incoming BPM one bar before a bar-aligned (section-boundary preferring) downbeat drop, then an equal-power master-gain crossfade (4 bars beatmatched / 2 bars plain). Key/BPM chips render on the now-playing line and queue rows. Pure math inruntime/src/dj.ts(unit-tested); the conductor inradio.ts. - Front page —
GET /: open jams (live now), this week's challenge, trending songs, newest packs, and the way into the workbench. Server-rendered, no client JavaScript. "Open jams" is apastesquery, not a room directory — see Listen live below. A trending card links at the song page (/s/<id>) and carries the song's own description line and BPM where it has them: the handler reads one blob per trending card, in parallel and best-effort, behind the page's 30 s cache — a card whose blob is missing simply says less. - Challenge page —
GET /c/:slug: brief, the starter (embedded), "Remix this challenge →", and the entry gallery (the starter's remixes). Created by the maintainer viaPOST /api/challenge(BearerADMIN_TOKEN). - Code golf — a challenge created with
golf: trueranks its entries by size, annotates each card with its byte count, and shows a leaderboard above the gallery. The score is the raw byte count of the stored source — not minified, not comment-stripped — and the page says so, because a score you can't reproduce withwc -con the source right there isn't one. Unscored entries (published before the column existed) sort last; ties break by recency. Column:pastes.source_bytes, distinct frombyte_size(which measures the whole blob, imports included). - Listen live — a signed-in owner attaches a room to their own song with
POST /api/paste/:id/live({room}to set,{room: null}to clear). The song page then shows a listen live chip to/r/<room>, and the front page lists it under open jams. This is also the only room enumeration on the backend: the relay is a Durable Object per id with no directory (Share music and collaborate §5), so a room becomes discoverable by its host choosing to advertise it — which keeps a private jam private for free. Nothing verifies the room is alive; a stale link 404s gracefully on join. - Feed —
GET /api/feed?sort=recent|trending&limit=N→ paste summaries. - Auth —
GET /auth/signin(the provider chooser),GET /auth/login?provider=github|discord(defaultgithub),/auth/callback,/auth/dev?as=&provider=(local),GET|POST /auth/link?provider=andPOST /auth/unlink?provider=(authed — attach/detach a provider on the CURRENT account),GET /api/me(user +providers+ a one-timehandleNote; plusdevAuth: true, signed in or out, when this Worker runs withDEV_AUTH— loopback only, and the login wall reads it to label its provider buttons honestly). Session = anAuthorization: Bearertoken from the callback fragment, kept inlocalStorage["volta:session"]. The payload is versioned: a token from an older Volta is refused with "sign in again", not silently half-honored. - Login gate (2026-08-25) — the browser workbench requires sign-in before it boots. Both workbench pages (
index.htmland its static twinpublic/studio.html) boot through ONE module,public/studio-boot.js, which runspublic/studio-gate.js— served verbatim likestudio-host.js, contract pinned byruntime/src/studio-gate.test.ts— before loading the UI wasm; neither page carries boot code of its own, so a gate cannot cover one and miss the other (runtime/src/studio-boot.test.ts). Admission: a session/api/mevouches for; an automated browser (navigator.webdriver— every headless drive inruntime/scripts/); or offline grace (service unreachable but the stored token is decodable and unexpired — the workbench stays usable through a blip). Everything else gets the sign-in wall (GitHub / Discord / chooser, withnext=back to the exact URL,#s=share payloads preserved through the round trip). The wall opens with one sentence about what Volta is — a first-time visitor arrives at a page that has not yet shown them anything — and, when/api/mereportsdevAuth, labels each button "· dev login", because in that mode it never reaches the provider at all. A door, not a lock: the gate is client-side by construction; enforcement stays server-side (AUTH_ALLOWED, per-action auth). Embeds, song pages,room.htmlandradio.htmlremain public — they are the outward funnel. - Usage metrics (2026-08-25) —
POST /api/eventstakes small batches of allowlisted event kinds (boot,heartbeat,signin,play,publish,room_join,repl_eval,export,learn_step; ≤16/batch, unknown kinds dropped) and aggregates them intousage_daily(who, unix-day, kind) — theai_usageidiom: counts only, no raw events, no sub-day timestamps; signed-in users keyedu:<uid>, anonymous surfacesip:<addr>.GET /api/metrics?days=N(ADMIN_TOKEN, like challenge seeding) answers the product story: total users + signups/day, DAU per day + rolling WAU/MAU, per-kind totals with distinct users, week-over-week return rate, and content counts (pastes/packs/likes). Client side:runtime/src/metrics.tsbeacons from the workbench page —boot+signinafter the gate, a 5-minute visible-tabheartbeat(minutes-in-app ≈ 5 × count), and the host commands worth counting sniffed off the UI→host stream (startAll→play,publish,export,joinRoom→room_join,learnLesson→learn_step). Automated drives beacon nothing. Server:web/src/metrics.ts; suite:web/test/metrics.test.ts(both stores). - Profile —
GET /@user: avatar, linked-provider badges, public pastes + packs, totals. - Like —
POST /api/paste/:id/like(authed) toggles; returns{liked, likes}. - Pack button — the COMMUNITY panel's packs ▸ publish…; same backend gate. Compile-checks the current file locally, prompts for a name (kebab-cased song name by default), and
POST /api/packs it with your session — publishing requires sign-in (packs always have an owner). Republishing a name mints the next version; identical content is a no-op (unchanged: true). - Room button — in the workbench header (
CommandId::JoinLiveRoom); same backend gate; needs sign-in (a room has an accountable owner). Toggles a live room: on, it mints a room id, connects as host, reflects?room=<id>in the address bar, and copies the/r/<id>listener link; off, it ends the room. Reloading a?room=<id>URL resumes hosting on the owning device, and sends any other visitor to the listener view. - Room page —
GET /r/:id(served by the Worker): the listener view — an<iframe>on the audience player (room.html?room=<id>), a copy-link, and OG tags. A pure render: room state lives in the Durable Object, so any id is watchable (there is no store record to miss). Since 2026-09-04 it also carries a Join and co-edit → door to/?edit=<id>on the workbench origin, with a line saying what it needs: signed in you arrive with a pen, signed out you land back in this same listener seat. The page was the one route into a room that only ever offered the listening half. - Audience view —
room.html?room=<id>: play/pause, a LIVE badge + a presence roster, an output scope, and "open a copy in workbench" (a#s=fork of what you're hearing, not a re-join). Registered as theroomvite entry; browser-only (hidden on the desktop build). - Room relay —
GET /api/room/:id/ws(WebSocket upgrade) forwards to the per-room Durable Object. The session token rides as a?token=query param (a browserWebSocketcan't setAuthorization); a host or editor must present a valid token, audience peers are anonymous. Because the token is in the URL, the origin is the credential's recipient, so choosing the relay is an authorization decision, not configuration. Anything that can name that origin — a?relay=query parameter, a pasted co-edit link — is attacker-reachable, so the shells only ever send a token to a relay on an allowlist (their own origin, the configured community server, or loopback in a dev checkout). The check sits where origin and token meet:studio-host.js::classifyRelayOriginin the browser (a write-role join to an untrusted origin is refused outright, not silently downgraded to audience) andui/src/app/room.rs::trusted_relay+token_may_go_toon the desktop (a pastedhttps://elsewhere/r/<id>link with a session is refused there too). A listener may point anywhere, and sends no token to an untrusted relay — the browser used to refill a listener's empty token fromlocalStorage, which handed the session to whatever relay a pasted link named. Any new shell that can join a room needs the same check before it sends a token. On the desktop, joining a room also suspends new native plugins until you leave (Host instrument and effect plugins). - Room document —
GET /api/room/:id/documentanswers the room's persisted rack as JSON:{ snapshot, clock, transport, macros, slots, seq, live, updatedAt }. It is the same content the Durable Object restores on a hibernation wake, so a room can be reopened, inspected or archived without joining it. Gated exactly like a write-role join, because it answers the same question about the same content: anonymous → 401; a room under a live host claim → its host's account or an account the host granted a pen → otherwise 403; a room nobody has claimed → any valid session. A room that was never written → 404. It reads the LIVE state when the room is awake (a jam's document is up to a debounce ahead of storage) and storage otherwise, and says which throughlive. The relay's frames — the wire contract, mirrored in four places that must move together:
web/src/room-protocol.ts(the reducer),runtime/src/room.ts(the browser client),app-native/src/collab.rs(the desktop) and the frozenroom.htmlaudience. Adding a frame means teaching all of them — the desktop's may recognise and ignore one, but deliberately.Frame Carries Who may send Relayed to snapshot{source, seed?, imports?, macros?}— the materialized songany write role (clients elect the publisher) others scene/play/stopthe transport host others clock{originServerTime, bpm, barSeconds, songBarBeats}host others ping→pongthe NTP-style offset probe anyone the sender presence{playing?, voice?, cast?, hear?}anyone (claims are scoped) all, as rostersync/awarenessopaque Yjs blobs, base64, per file key fwrite roles with that file's pen the peers holding ffileswhich module files this peer holds anyone all, as rosterrole/scopea peer's pen, whole or per file host all signalan opaque WebRTC handshake for ONE peer write roles that peer vmixone opaque Opus packet of the voice mix the elected voice publisher listeners that announced hearmacro{slot?, name, value, ramp?}— one knob movewrite roles; a slot's macro needs that slot's claim others slot.add/slot.removeslot id, ref, owner host for anyone; a write role for its own all slot.programslot id, source, opaquestate/ctxthe slot's owner or co-players others slot.stateper-path ops with Lamport stamps the slot's owner or co-players others (accepted ops only) slot.notenoteOn/noteOff/oneShotwith a beat stampthe slot's owner or co-players others slot.runningthe slot's running track set the slot's owner others slot.coplaya slot's co-play allow list the slot's owner or the host all hello/rosterwho is here, their roles, and slot ownership relay the joiner / all
- Macro sharing —
RoomSnapshothas declared amacrosmap since Stage 1 and nothing ever sent one, so a listener rendered the host's song with its knobs wherever the SOURCE left them.{t:"macro"}closes that: a write role's knob move reaches every other peer and is applied there through the engine'sset_macro(orramp_macro, when the move carries arampin ms — a glide is rendered from where the RECEIVING peer's knob is, so two peers that drifted apart converge on the room's value without either jumping to the other's starting point). The relay never smooths: the SENDING client coalesces per(slot, name)to ~30 Hz (MACRO_COALESCE_MS, leading edge plus a trailing flush), which is above the rate a hand carries information at and an order of magnitude under what a pointer-event-per-value drag would put on the socket. The reducer folds each value into the room's live macro map, so a late joiner'shellocarries the knob where the ROOM left it, not where the published snapshot did; a freshsnapshotsupersedes that map, because a snapshot is the publisher's whole state. Ramps are relayed and never stored — a glide is a gesture in flight, and replaying one to somebody who joined after it finished would sweep a knob for no reason. - The rack: per-slot ownership — a room can be a RACK of slots (an instrument each: a toy, a synth, a utility), which is per-slot ownership as a role concept.
role/scopescope a peer's pen over FILES; theslot.*family scopes it over INSTRUMENTS. Each slot carries an owner (a peer id), a co-play allow list the owner or the host edits, its committed program, its per-path state, its macros and its running set. The reducer enforces that only the owner, a co-player or the host may drive a slot, and narrows that again for the frames that speak for the slot's SEAT (remove, running set, co-play list) to the owner or host alone — a co-player plays the instrument, it does not get to give it away. State ops are last-writer-wins per path, ordered by the sender's Lamport stamp and tie-broken by peer id, and only the ops the relay ACCEPTED are broadcast, so every peer lands on the document the relay holds rather than re-running the race locally. A slot's owner is presence-scoped: a restored room's owner is a peer id from a session that has ended, so the first write role to re-slot.addthat id takes the seat — which is what makes a reopened room playable. A room that never adds a slot is unchanged on the wire:helloandrosteromitslotsentirely, and none of the new arms is reachable. - Who gets a pen — asking for
role=editoris a request, not a grant. In a room under a live host claim a signed-in joiner lands as audience and appears in the roster; the host hands over the pen from there, and the grant is remembered by account so a reload keeps it. A room nobody has claimed as host has no owner to protect, so any signed-in peer may write in it. - Peer liveness (2026-09-04) — a tab that navigates away does not reliably close its WebSocket, so the relay used to hold a socket for a peer that was gone: an anonymous listener sat in every roster as "listening" for the room's whole life. Two answers, belt and braces. Every shipped client now
leaves onpagehide— instant, and the normal path. As the backstop the reducer keeps aseenstamp per peer: a peer that has proven it pings (ping, every 5 s in every client) is enrolled, andsweepdrops any enrolled peer silent forPEER_TIMEOUT_MS(15 s — three missed pings) and closes its socket, which is not optional because the roster is rebuilt from live sockets on a hibernation wake. A storage alarm everyPEER_SWEEP_EVERY_MS(5 s) gives the sweep a clock of its own — a room whose only remaining peers are dead sockets sends no traffic to piggyback on — and is armed only while somebody is enrolled, so an idle room wakes for nothing. A peer that has never pinged is never enrolled and therefore never swept: dropping a client that legitimately sits silent would be a worse bug than the linger. Both constants live inweb/src/room-protocol.ts. Two related fixes ride along: the DO clears the stored transport when the room empties (a room id played in an earlier session used to greet its next guests with a stale{t:"play"}; the snapshot and clock stay, because "a paused jam resumes" is about the song), and co-editor presences are held against the live roster rather than waiting on Yjs's own 30-second awareness sweep, so a departed guest's caret goes with her. The mirror of that — the LEAVER's own view — closed on 2026-09-04 (browser QA, C4):YRoomProvider.destroyremoved only our own client from each file's awareness table (andAwareness.destroyremoves nobody), whileStudioRoom.closesetclosedbefore saying anything, so the last status a shell heard was the one from while the room was still full. Leaving now clears every client from every file's table and emits one parting status — disconnected, empty roster, no presences — before the shutter comes down, so the person who left stops seeing a peer's caret and name in a buffer that is no longer shared with anybody. - Co-edit — the room dialog's co-edit invite; same gate; needs sign-in. Toggles a shared-buffer session: on, it mints a room, binds the editor to a shared Yjs
Y.Text(yCollab), reflects?edit=<id>in the URL, and copies a/?edit=<id>invite; off, it unbinds. Opening a?edit=<id>link (signed in) joins as a co-editor; the creating device re-seeds from its buffer, a joiner pulls the doc via sync. - Joining says what it is doing (W1, 2026-09-21). The browser's
joinRoomwas not instudio-host.js'sPAGE_SIDE_COMMANDS, so a join clicked before the engine had finished booting was QUEUED behind it: the button went down and nothing happened at all until the engine came up (the walkthrough waited past ten seconds and clicked again). A room is a socket, not a graph — it is page-side work now, likepublishbeside it. The dialog also shows the connect: pressingjoinreplaces the form with Connecting…, then Waiting for the relay… once the socket is open and unanswered (relayOpenin the status blob — open AND not yethello), then the connected view; a refusal is stated with Try again and Back rather than dropping you onto a form that looks untouched. The progress view asks for its own repaints, because a connect in flight produces no frames. - Follow the host's transport is ON by default for everyone but the host (W3, 2026-09-21). A listener that was not following heard the jam out of time; an editor that was not following sat silent while the room sounded. The host is the clock, so it is not offered the box at all.
- The header names the room (W7): while connected the ROOM button reads
room · <id> · <peers>(VoltaUi::room_chip_label, cached by the room pump). - The host leaving is said out loud (W13): every peer gets the toast "The host left — the room's transport stopped" on the roster edge, and follow-host is dropped with it — there is no longer a transport to follow.
- Leaving closes the dialog (W11) and clears the source pane's
Hosting …line. - Which tab the room edits (2026-09-04). A room's document replaces the buffer wholesale, so joining gives it a tab of its own named
room · <id>— a jam arriving from somebody else is never written over the song you had open, and ⌘S in it goes to the room's own scratch file (Songs and files, "wire songs"). Hosting is the other case: the room is seeded from this buffer, so the shared document already IS the song you have open, and there is nothing of anyone else's to clobber it with. A host with a real document open therefore keeps editing that document — the tab, the file and ⌘S are unchanged — and the join says "Hosting <id> — you're sharing <song>", naming the DOCUMENT (its file stem, e.g.untitled-1) rather than the tab strip's fallback word (W5, 2026-09-21: the toast said "untitled" over a song chip readinguntitled-1). Forking there put the host in a scratch tab their edits landed in and their song did not, and leaving the room left the extra tab behind. A host whose live buffer is pathless still gets the room tab, because there is nothing to bind to. - A remote caret's name flag lives in the gutter (W9, 2026-09-21). It used to be drawn above the caret, inside the text column — which is the previous LINE in a monospace grid, so a peer's cursor hid that line's first characters for as long as it sat there. It is now a small flag in the gutter on the caret's own row, trimmed to the gutter's width (
ui/src/editor/layout.rs::remote_caret_flag); a cell editor's stub gutter gets no flag at all. - A hidden tab keeps up (W8, 2026-09-21). The workbench is a canvas, so everything it does runs on an animation frame — and Chrome suspends those in a background tab while throttling
setTimeoutto as little as once a minute. A backgrounded collaborator therefore stopped taking part: the socket kept delivering, but the work hanging off the room's timers (a host's debounced snapshot, every peer's status) was throttled with them. Two wake-ups that are not animation frames now carry it: the arriving frame itself (RoomHandlers.onFrame→StudioRoom's hidden-tab flush) and a 250 ms keepalive the page arms whiledocument.hidden(runtime/index.html→StudioRoom.pumpHidden). The CRDT always applied remote edits on arrival; what is fixed is everything that was waiting for a timer. The canvas still cannot paint while hidden — the buffer catches up on the first visible frame. - Registry imports — write
import "@user/name" (…)anywhere. On the desktopvolta-sourcesresolves the spec LAST, after every local path, and caches it under~/.volta/registrywith a.sha256sidecar (registry/src/lib.rs;VOLTA_REGISTRYoverrides the API base,VOLTA_CACHE_DIRthe cache). In the browser (2026-09-16) the preparation worker resolves it before the synchronous compile — the wasm compiler takes a finished module map, so the fetch cannot happen inside it — recursively, since packs can import packs, verifying the declared sha-256 and memoizing the answer for the session. A local buffer of the same key still shadows the pack. Details and the one remaining gap (an editor squiggle that only clears after the first Run) are in Names and library imports §"One bundle-gatherer". Thelibvolta, plugin-target and LSP builds turn the registry OFF by feature, so a pack spec there is simply an unresolved module. - One semantics (Module Hardening) —
registry/src/lib.rsis the authority;runtime/public/studio-module-sources.jsmirrors it. A response must declarehashandpackHash(missing = refused, mismatch = error); cached copies are re-verified on every read. Unpinned and unlocked offline is an error. Inside a pack a plain import names a sibling module first (the bundle is still keyed by written path, so a same-named recipient module collides).@volta/<lib>is the installed stdlib, never the network. volta.lock— see Versions, history and comparisons.volta update [@u/name]moves it;volta outdated [--json](andregistry::outdated) lists locked vs latest.- Resolve API —
GET /api/registry/@u/name[@N][/sub]→{ spec, version, hash, packHash, source, modules: {sub: {source, hash}}, samples: {key: {hash, bytes, ext}}, license, tags, yanked };GET /api/registry-file/@u/name@N/<key>serves sample bytes. Pinned responses are immutable-cached.GET /api/packs?q=&tag=&sort=installs|recentsearches (qsubstring over name/description/tags,tagexact, ANDed);GET /api/pack/:owner/:name→{ pack, versions }with yank flags. - Publish rules —
POST /api/packneeds a license (CC0-1.0, MIT, CC-BY-4.0, CC-BY-SA-4.0,custom:<text>), refuses a mismatchedsourceHash/packHash, caps each file at 64 KB and the pack at 1 MB, allows 10 new versions per owner per hour, and compiles the entry with the Worker's compiler (422). Admin takedown purges the bytes (shared blobs survive) and pinned fetches answer 410 "removed by moderation". - Yank —
POST /api/pack/:owner/:name/:version/yank(owner only,{yanked?: bool}, reversible). npm semantics: a yanked version stops being selected aslatestand leaves search/browse, but a pinned@user/name@Nimport still resolves to the same bytes — publishing an immutable version is a promise; a yank is a recommendation. Yank every version and the pack has nolatestat all (/api/registry/@u/n404s and says why) and drops out of browse, while staying on the owner's profile so they can undo it.latestVersionis the monotonic mint counter;latestLiveVersionis what an unpinned import gets. The resolvers needed no change —latestselection is server-side, so the CLI, the browser workbench and volta-studio all inherited it. - Pack demo —
POST /api/pack/:owner/:name/demo(owner only,{paste}/{paste: null}) attaches a demo song, or the module header carries-- demo: <paste id>(a pasted URL is tolerated). The pack page plays it through the Stage 1 embed; a missing or deleted demo renders nothing. - Registry pages —
GET /packs(browse/search, tag chips + a tag cloud computed from the results) andGET /p/@user/name(import line, demo player, latest source, version list with yanked versions dimmed). - CLI resolve —
volta song.voltaresolves registry imports too: cache at~/.volta/registry/<owner>/<name>@<v>.volta,VOLTA_REGISTRYoverrides the API base (e.g.http://localhost:8787/api),VOLTA_CACHE_DIRthe cache; offline "latest" errors and names the pin that would work, unlessVOLTA_REGISTRY_OFFLINE=1opts into the newest-cached substitution. A local<spec>.voltafile next to the song shadows the registry. Integrity is checked on every read, not just on fetch. A verified fetch writes the server's digest to a<cachefile>.sha256sidecar; each cache read — the pinned path, which never touches the network, and the offline substitution — recomputes and compares. A mismatch is a hard error naming the file and both digests, so a rotted or edited cache can never reach the bundle. A cache with no sidecar (written before this) still reads, and gets its sidecar backfilled so the next read is checked. CLI publish —
volta publish [--name <n>] [--license <SPDX>] [--tags a,b] [--about <text>] [--dry-run] <file.volta>. Compile-checks locally (the server stores text and runs nothing, so the client is the gate), takes the name from--nameor the kebab-cased file stem, the description from the module's--header blurb, and the license from--license(defaultCC0-1.0). Republishing identical bytes printsunchangedand mints nothing, so a CI step can run on every commit.--dry-runshows exactly what would be sent without a session or a network call.Authentication reads
~/.volta/session.json— the file volta-studio's sign-in writes. There is deliberately no CLI OAuth flow: a second loopback listener and a second token file would be a second thing to get wrong, so the desktop app signs in and every native tool reads the result. Signed out, the command says how to fix it; signed in to a different server thanVOLTA_REGISTRYpoints at, it names both rather than behaving as though nobody is signed in. The store lives inregistry/src/session.rs(shared withapp-native), the wire half inregistry/src/publish.rs, the command incli/src/publish.rs.
In the Rust workbench#
The converged workbench (Workbench Convergence) is the volta-ui crate, mounted by volta-studio (desktop) and ui-wasm (browser). It reaches the same backend through one bridge seam, so both shells get the same surface.
- COMMUNITY panel — a right-column slot (header toggle
community), four tabs:feed —
recent/trending, one card per song: title (13 px), the description's first line orno description, a relative date (3d ago— fromcreatedAtagainst the shell'swall_ms(); a shell with no clock shows no date rather than one measured from the epoch), and plays / likes / remixes. Actions (12 px): open (fetches the song and drops its source in the editor), ♥ (like/unlike; needs a session), copy link (the/s/<id>permalink), ▶ radio (start the program here).PasteSummarycarries neither a description nor a tempo today, so those fields decode-and-degrade: the day the backend sends them, the card shows them unchanged.A successful publish refreshes the feed: the panel drops the cached list, returns to this tab and marks the new card
just published(CommunityPanel::published, called by whichever half minted the link — the desktop's synchronouspublish_pasteor the browser'spublish_status). Before 2026-09-21 the one thing a person wanted to see after publishing — their own song on the feed — was the one thing that needed a manualreloadclick.- packs — search +
installs/recentsort overGET /api/packs, plus tag chips on every row: clicking one filters browse to that exact tag, and the active filter shows as a chip you can click off (an exact filter you can't see is a list that has silently gone quiet). detail fires two requests — the resolve (source + metadata) andGET /api/pack/:o/:n(the real version list) — and lists every version as a pinned-import chip. Yanked versions are marked and still copyable, because@Nstill resolves; if you are the owner, each version also carries yank / un-yank. A browse card offers each action ONCE — details, copy import (whose tooltip is the exact line it copies) and by @handle — and theinstalls/recentsort chips are quiet navigation beside apublish…command chip, rather than three identical-looking filters. publish… opens the pack form: kebab-cased name, description prefilled from the module's--header blurb, a license picker (CC0-1.0 / MIT / CC-BY-4.0 chips over a still-editable field — the backend accepts any label, so the list is a shortcut, not a policy), a tags field (comma-separated, validated locally against the backend's shape), and a local compile-check before anything leaves the machine — the desktop twin of the web pack button. Authed-only, per the backend. radio — auto-advancing playback of the feed (~45 s per track, like
runtime/src/radio.ts). Stopped, it saysRadio is off — press ▶ to play the feed in order; the queue rows carry tempo and length when the backend knows them, and nothing where it does not. Each track is fetched, hot-swapped in, started, and beaconed (POST /api/paste/:id/play). It is pumped from the frame path, so it keeps playing while you work in another view. A track that fails to fetch or compile is skipped, not fatal. No auto-DJ yet — the Camelot seam (runtime/src/dj.ts) is browser-only.A transport stop stops the radio. ⌘. / Transport ▸ Stop All / the ■ STOP chip all drain through one place in the shell, and that place calls
CommunityPanel::stop_radio_for_transport, which switches the program off exactly as the panel's own ■ stop button does (a toast saysradio off — transport stopped). Radio is driven by nothing but the wall clock, so before this it read a stop as "the track ended": silence, then the NEXT paste hot-swapped in with aradio · …toast, and the panel countingnext in 44sunder a program the user had stopped. A track whose fetch was already in flight when the stop landed is dropped on arrival, not played or beaconed, and the countdown never renders while radio is off or paused (2026-09-04 browser QA, C1).- profile — a labelled
look up @handlefield overGET /api/user/:login(the JSON twin of/@login, added for this), and a profile header: the handle, the display name,N songs · M packs, andfirst song here <when>derived from the oldest listed song — the API carries no join date, so the line says what it actually knows. Clicking any author chip opens it; a signed-in user lands on their own. - invites — only when the server ANSWERS with a non-zero quota. A probe that FAILED is not an answer: the tab stays and shows
couldn't load invites — retry(Invite someone to Volta).
- Account chip — in the header, beside the fps readout. Signed out it opens the COMMUNITY panel rather than starting a sign-in: there is more than one provider, and a header chip that silently picked GitHub is how somebody ends up with two accounts, one of which owns their packs. Signed in it shows
@handle. The panel's own account chip is where the choice lives: signed out it is asign in ▾menu with one entry per provider (and the line "your @handle is claimed once, on first sign-in"); signed in it is the panel's account indicator —@handlebeside anaccount ▾menu holding every account gesture: link/unlink the other providers (with "unlink" disabled on the last one, explained on hover, rather than letting you earn a 409) and Sign out…, which arms a confirm naming the account (Sign out of @you?) and answers with an ordinary toast. Signing out was the most prominent control on the page until 2026-09-21 — an outlined destructive chip, one click, with a 9 px "signed out" as its only feedback. The server origin is on the chip's tooltip (connected to …), not printed under the tabs. - Dev logins say so. When
/api/mereportsdevAuth(a loopback Worker withDEV_AUTH=1), every sign-in entry readsGitHub · dev login/Discord · dev login, exactly as the sign-in wall's buttons do — the click mints a throwaway local user and never reaches GitHub. The page learns it from the authed/api/me, or, signed out, from one anonymous probe, and pushes it beside the identity (push_community_auth'sdev_auth). - A sign-in comes back where it started. An OAuth round trip is a full navigation, so the page writes the view it is leaving into
sessionStorage["volta:signin-return"](rememberSignInView, the workbench's ownoverlay=token) and, once/api/meconfirms the identity, restores it withshell_command {"type":"setView"}and saysSigned in as @youin the toast strip. The marker — not the#session=fragment — is what says a load is a return trip:studio-gate.jsruns first on both pages and has already consumed the fragment. A marker older than 15 minutes is ignored. - Publish — File → Publish to Community… names what it is about to do:
Publishing <song> · N lines · unsaved edits included, that it goes on the public feed, and — when the buffer arrived as a remix —This is a remix of '<title>' by @<author>. It sendsAuthorization: Bearerwhen signed in (so the song is owned: byline, profile, supersede) and rides imports by value; the dialog states which it is doing. The server field is a development control and lives behind an Advanced disclosure.POST /api/pasteacceptstitle,source,importedSources,parent,updatesandcoauthorsand no description or tags, so the dialog offers neither (web/src/handlers.ts::createPaste). - A failed publish says so, in the dialog, in words. The raw error goes to the console/stderr;
publish_failure_sentence(ui/src/community.rs) maps it onto one sentence above the button — aTypeError: Failed to fetch(CORS, offline, a Worker that is not running) becomes "Couldn't publish: the community service didn't answer (network). Try again.", and 401 / 403 / 429 / 413 / 404 / 5xx each get their own. The button stays live, because a failed publish is a retry.publish_status()is polled every frame the dialog is open rather than only while it believes a request is in flight, so a late or unexpected answer cannot be dropped on the floor (the 2026-09-21 walkthrough found a publish failing with nothing on screen at all). - The remix hand-off — the page is the only half that sees
?remixOf=, so it says the sentenceRemixing '<title>' by @<author> — publish to post your versionthrough the shell-notice seam. The panel shows it as a toast AND keeps the lineage for the publish dialog (REMIX_NOTICE_PREFIXis the contract, pinned on both sides); a toast is gone in seconds, and the fact that this is somebody else's song matters again at publish time. volta login— device code (registry/src/login.rs,web/src/device-auth.ts): approve the printed code at/auth/devicein any browser; the CLI's poll receives a fresh session in~/.volta/session.json.- Registry imports — volta-studio resolves
import "@user/pack[@N]"throughvolta-registry(registry/, extracted from the CLI's old resolver so the CLI and the workbench share one implementation and one~/.volta/registrycache). A local<spec>.voltabeside the song still shadows the registry;VOLTA_REGISTRYstill overrides the base, and its origin is also the community server the panel and the publish dialog point at. Resolutions are memoized per process — the workbench re-gathers imports on every debounced re-analysis, and that must not become an HTTP request per keystroke.
Signing in from the desktop#
There is no origin to redirect a desktop app back to, so volta-studio does the standard native-OAuth dance:
- bind a one-shot listener on an ephemeral loopback port;
- open
{server}/auth/login?provider=<p>&next=http://127.0.0.1:<port>/cbin the system browser (the user's provider session and 2FA live there, not in our process); - the Worker's callback redirects to that loopback URL with the session in the fragment.
Linking a second provider is the same dance with one extra step at the front: /auth/link needs the session, and a browser navigation cannot set an Authorization header, so the shell POSTs it (Bearer attached) and gets back {url} to open. The token therefore never enters a URL. The round-trip ends exactly where a sign-in does — a fresh session for the same user on the loopback listener — so the capture code is shared verbatim.
A fragment is never sent to the server it points at, so the listener can't read it off the request line — and putting the token in a query parameter instead would write a bearer credential into browser history and every request log on the way. So GET /cb answers with a nine-line page whose script POSTs location.hash back to /cb-token. The token therefore exists only in the redirect's Location header (already true of the browser flow) and in a loopback POST body.
sanitizeNext (web/src/auth.ts) accepts three kinds of next and nothing else: the workbench origin (WORKBENCH_ORIGIN), this Worker's own PUBLIC_ORIGIN — the pages that send people to sign in are the ones it renders, so a song page's ♥ (/auth/signin?next=…/s/:id) has to be able to land the visitor back on that song — and a loopback capture URL, on rules that stay narrow: plain http:, a loopback host (127.0.0.1, [::1] or localhost), an explicit port, no embedded credentials, and any caller-supplied fragment dropped.
Anything else is refused with 400 next must be the workbench origin or a loopback URL — it is not rewritten. An absent next is not a refusal: that is the default landing (the workbench). Every acceptance and every refusal is a test in web/test/auth.test.ts. (In production the two origins are the same, so the PUBLIC_ORIGIN rule only ever shows up in a dev split — which is exactly where the missing rule was found.)
Both of those last rules changed on 2026-09-04 (browser QA, C3). localhost was off the loopback list on the theory that a hostile resolver could point it elsewhere; RFC 6761 reserves the name for the loopback interface, and the exclusion had a real cost, because the whole dev split runs on localhost:<port> URLs. GET /auth/dev?as=bea&next=http://localhost:5177/ was refused, silently rewritten to WORKBENCH_ORIGIN, and landed on http://localhost:5176/ with bea's #session= in the URL — one user's token planted in another user's origin, where every open tab read it, and no word anywhere about the port that was actually asked for. A refusal that redirects somewhere else is not a safer refusal.
The captured session is confirmed against GET /api/me before it is written to ~/.volta/session.json (mode 0600), tagged with the server it belongs to — a token minted by wrangler dev is never replayed at the public instance. Signing out deletes the file; sessions are stateless HMAC tokens, so there is no server-side revocation to call.
Local end-to-end: cd web && npm install && npx wrangler dev (DEV_AUTH is 1 in wrangler.toml), then run volta-studio with VOLTA_REGISTRY=http://localhost:8787/api. GET /auth/dev?as=alice (add &provider=discord for the other one) is the OAuth-app-less login path the loopback flow lands on — and dev:github:alice and dev:discord:alice are two different accounts, so the handle-collision path is reachable locally.
A mixed jam on one machine — the jam lab. make jam (scripts/jam-lab.mjs) starts a private Worker (its own local D1 under .jam-lab/, DEV_AUTH, free ports) and vite, mints a dev session per person, and opens the scenario: a desktop Studio per desktop person (its own VOLTA_SESSION_DIR and song copy, --host/--join, the window tiled with VOLTA_WINDOW and titled with VOLTA_WINDOW_TITLE) and a real Chrome per browser person (its own profile, the session seeded into localStorage over CDP — a #session= return would be refused, rightly, by V-7). Scenarios: desktop-hosts (default), browser-hosts, desktops, full-house, host-leaves, or any mix with --peers alice:desktop:host,bea:browser,guest:listen. --check drives and verifies one (desktops through their agent socket's studio_* tools, whose studio_status now reports the room; browsers through the page): roles, rosters, follow and beat lock measured on the clock, edits converging across shells, stop, and a late guest's automatic ask. make jam-check runs the four main scenarios. Guests join BEFORE the host in --check so they hold pens without anyone clicking Allow; --stampede sends everyone at once.
node runtime/scripts/auth-providers-e2e.mjs drives the whole multi-provider story against a real wrangler dev + local D1: sign in on each provider, a cross-provider handle collision, linking, @handle/name resolving identically across a link and an unlink, profile badges, and a v1 token failing closed.
Not verified here: the real GitHub and Discord authorize/token round-trips. There is no OAuth app registered for this project, so
githubIdentity/discordIdentity's HTTP shapes are exercised only by the shape of the code — exactly as the GitHub flow was before Discord existed. Registering both apps and setting the four secrets is a §10 deployment item.
In the browser shell the page owns all of this: runtime/public/studio-host.js captures #session= out of the callback fragment, stores it under localStorage["volta:session"] (the key runtime/src/auth.ts and the server-rendered pages share — one sign-in covers the origin), confirms it against /api/me, and performs each request the wasm asks for, echoing the reply back by correlation id.
Three page-side rules make that hold up when the page and the Worker are not the same origin (the dev split: vite on :5173, wrangler dev on :8787):
- One notion of "the server".
community_server()reports the API server's ORIGIN — the page answers acommunityServerquery with it, derived fromVITE_PASTE_API, instead of the wasm reportinglocation.origin. Dialogs seeded from it (publish, rooms) and the panel's permalink copy therefore name the host that actually serves them. Anything that turns that origin into a request URL goes throughapiBaseOfinstudio-host.js(origin →origin + "/api", already-an-api-base left alone); rooms keep using the origin, since the relay URL is<origin>/api/room/<id>/ws. - Community commands do not wait on the engine.
publish/community/ the sign-in family are dispatched bystudio-host.jseven before the worklet is ready — they touch the network andlocalStorage, never the engine. Queued behind the engine gate, a publish clicked during boot simply never ran, and the dialog sat on "publishing…" with no error to show. - Identity cannot drift. Every request reads the token fresh, so a sign-in in another tab of the origin changes who this tab publishes as immediately. The host listens for the
storageevent onvolta:sessionand re-runs/api/me, pushing the answer back throughpush_community_auth— so the account chip and the token on the wire are always the same person. (runtime/src/studio-host-community.test.tspins all three.)
How it's wired#
ui/src/bridge.rs— the seam:community_server/community_auth/community_sign_in/community_sign_in_with/community_providers/community_link_provider/community_unlink_provider/community_sign_out/community_start/community_poll, all defaulted (a bridge without a transport hides the surface).community_sign_in_with(provider)forwards to the zero-argumentcommunity_sign_in()unless a shell overrides it, so every minimal bridge — volta-shot's, the plugin target's — keeps compiling and keeps working; that forwarding is pinned by a test inui/tests/community.rs. OneCommunityQueryper backend call; replies are the raw JSON body, correlated by the idcommunity_startreturned.CommunityQuery::wire()builds(method, path, body)once, for both shells.ui/src/community.rs— the panel: decoding, tab state, the radio program, and the pure helpers (kebab,header_blurb,valid_pack_name) that mirror the web publish flow. Tested inui/tests/community.rs(kittest, fake transport) plus unit tests on the wire contract.app-native/src/community.rs— the desktop transport: the session store, the loopback capture, and a worker-threadureqclient (the UI thread never blocks on the network).ui-wasm/src/web.rs+runtime/public/studio-host.js— the browser transport: the wasm emits{type:"community", id, method, path, body}, the page fetches, andStudio.push_community_reply(id, ok, body)/push_community_auth(...)/push_publish_result(...)push back.web/src/handlers.ts—GET /api/user/:login, the JSON profile the panel reads (/@loginis server-rendered HTML a non-browser client can't consume).
A link to a one-oscillator song looks like:
https://volta.fm/#s=N4IgdghgtgpiBcIQBoQ... (lz-string, URI-safe)
How it's implemented#
- Compiler: none — the share layer is pure runtime. It reuses the shipped
compile(src, sources)bundle boundary (Reuse sounds and patterns with imports). - Runtime:
runtime/src/share.ts— the codec.SharePayload={ v, source, name?, imports?, macros? };encodeShare/decodeShareover lz-string's URI-safe variant;buildShareUrl;readShareFragment(pure over a hash string) /readShareFromLocation.decodeSharefails closed — a malformed or newer-vblob returnsnullrather than a half-payload. Covered byshare.test.ts.runtime/public/studio-host.js—boot()reads the fragment first and gives it precedence over?paste=/?song=/autosave, installs the payload's by-value imports before the first compile, and lands the song as a real document (landWireDocument). A payload'smacrosare read but NOT applied: there is no seam to seat them in the wasm shell's macro panel, so the host warns rather than playing values the panel is not showing. (The TypeScript workbench's share button and itssetPendingMacrosrack-seeding were deleted 2026-08-09; see git history.)runtime/src/modules.ts—ModuleLibrary.addSourcesmerges import sources carried by value, without shadowing the recipient's shipped stdlib; registry-fetched packs land through the same door and reportorigin: "registry"in the module browser.runtime/src/registry.ts(+registry.test.ts) — the pack-spec grammar (parsePackSpec/isPackSpec,/subincluded), the wire types andpublishPack. Resolution lives inruntime/public/studio-module-sources.js(createPackReader,gatherModuleSources, the lock andpackHashOf), driven by the preparation worker; pack publishing runs in the Rust UI (ui/src/community.rs) over the community bridge.registry/src/lib.rs— the native resolver (cratevolta-registry, shared byvolta-cliand volta-studio): same spec grammar and wire contract,~/.volta/registrydisk cache with sha-256 sidecars,ureq(never the compiler, which stays network-free).gather_importsincli/src/main.rstries a local<spec>.voltafirst, then the registry.runtime/embed.html+runtime/src/embed.ts— the embed player. It builds the realRuntimeStatewith a stripped, mostly-no-opRuntimeDeps(getEditorSourcereturns the decoded share source), decodes the fragment,recompile()s, and on the play gesture warms assets thenplaySong. Only a module with no song block at all takes the start-every-track path now: the branch used to besections.length > 0, which played a WRITTEN-timeline song as silence, since it declares no scenes and a region track's legacy event list is empty on purpose. The output scope readsRuntimeState.masterAnalyser()→Mixer.masterAnalyser()(a browser-only accessor, not on the sharedAudioRuntimeinterface — the native backend has no Web Audio analyser). Registered as theembedvite entry.- Video export (
recordVideo, aMediaRecorderover a branded scope) was deleted with the TypeScript workbench, 2026-08-09; see git history. runtime/embed-test.html+runtime/src/embed-test.ts— a dev harness (likeosc-test.html): edit a song, live-previewembed.htmlin an<iframe>, exercise macro restore and the size warning.runtime/src/paste.ts(+paste.test.ts) — the paste client:publishPaste/fetchPaste/fetchRemixes/beaconPlay/uploadOgCard, thepasteApiBase()/pasteBackendConfigured()config.runtime/public/studio-host.jshandles the?remixOf=boot param (it names the buffer after the parent paste); the Rust workbench's own publish runs throughCommunityQuery::Publishon the community bridge.runtime/src/og-card.ts—renderOgCard(module, title)draws a 1200×630 PNG (title + the wavescape, reusingwavescapeRowsexported fromlens.ts) on a canvas andtoBlob()s it.publishCurrentSonguploads it best-effort after the paste lands — a card failure never sinks the publish.runtime/src/song-player.ts— the shared headless playback core (SongPlayer: build the realRuntimeState,load/play/stop/masterAnalyser;startScopefor the oscilloscope). The embed player, the radio and the audience view all use it, so the audio path lives in one place — and since Unified Timeline Stage 7 so does the FORM.playapplies the song's opening scene and thenarm_arrangement, handing the walk to the engine's own arrangement runner: the same plan, from the same implementation, that the workbench plays. The page-side scheduler that used to walk it fromsetTimeouts is gone, along with the possibility of these surfaces disagreeing with the workbench about where a transition lands. What a page displays it READS, off each viz frame'sAR01trailer throughruntime/src/arrangement-frame.ts— the scene owning the bar, the edge riding it, bars-into-step, and the graph decision log — withLV01's live launch coordinates decoding the same way. Both trailers are optional, so a page served against an older engine simply shows no walk.When a song is over, the engine says so.
AR01'sendedflag (bit 4 of its flags byte) means a finite form —arrange … |> once— has run past its last bar. It exists because there was no other way to ask: the engine keeps reportingplayingafter the end on purpose, since the transport is still running and tails are still ringing, so a page watching the transport never learned the music had finished and sat holding a ■ stop button over silence.SongPlayer.onEndedis the page-side subscription (the embed player flips its button back to ▶ and says "ended");native-runtime.ts'sfollowWalkclears the running mirror on the same frame. A looping schedule and asongGraphnever set it — neither has an end.Scenes with no
arrangedo not cycle. A song that declaresscenesbut no schedule and no graph has no form to walk, so the engine declines the arm and every surface — these pages and the workbench alike — falls back to starting every declared track. This is a deliberate change from the retired page scheduler, which rotated such a song's scenes on a ~14-second wall-clock timer: a duration nothing in the song asked for, that the workbench never played, and that made the same paste sound different on the radio than in the app. Scenes without anarrangeare a palette to launch by hand, not a form. To hear a song move on its own, give it anarrange(or asongGraph).runtime/scripts/arrangement-parity-test.mjsasserts this shape on both sides.runtime/src/dj.ts— auto-DJ pure math: Camelot mapping, metadata extraction, transition cost + queue walk, seam scheduling (Continuous mixes with Auto-DJ).runtime/radio.html+runtime/src/radio.ts— Volta radio: pullsfetchFeedfrompaste.ts, plays each track through aSongPlayer, auto-advances, bumps plays viabeaconPlay.radiovite entry.runtime/src/room.ts(+room.test.ts) — the room client (Stage 5).RoomConnection(injectable socket, outbox-then-flush on open, message dispatch),ClockSync(NTP-min-RTT offset estimate) +localOriginTimefor later phase-alignment,roomWsUrl/newRoomId,joinRoom, andRoomHost(debounced + deduped snapshot pushes + transport). Wire types mirrorweb/src/room-protocol.ts(lockstep, likeshare.ts↔web/src/share-link.ts).runtime/src/studio-room.tsis the page-side client that drives it for the wasm workbench (host wiring, the?room=/?edit=boot resume);SongPlayergainsplaySongByName/selectSceneByNameso an audience follows the host's chips.runtime/room.html+runtime/src/room-view.ts— the audience view: joins the relay as audience through the sharedSongPlayer, loads each host snapshot (hot-swapping a running graph via §5.5's atomic swap), follows transport, and shows the roster + output scope.roomvite entry.runtime/src/coedit.ts(+coedit.test.ts) — CRDT co-editing (Stage 5 slice 2).YRoomProviderbinds a sharedY.Text("source") to aRoomConnection(roleeditor), running the y-protocols sync + awareness handshakes over the relay's opaquesync/awarenessframes (base64 vialib0); it re-announces awareness when the roster grows so late joiners see present editors, andseedIfEmptyseeds the doc from the owner's buffer pre-bind. DOM-free (yjs only, no CM6) so it unit-tests in node — convergence proven through a fake broadcast hub.runtime/src/studio-room.tsdrives it for the wasm workbench: it joins asaudience/editor/host, converts every caret between Yjs's UTF-16 indices and the Rust editor's byte offsets, and pumps the doc back throughStudio.- Clock-sync (Stage 5 slice 3) — the beat-lock.
room.tsClockSync(NTP-min-RTT offset overping/pong) + the inverse pairserverTimeForCtx(host publishes) /localOriginTime(audience maps back), round-trip unit-tested.RoomHostgainsstartPing/serverNow/pushClock;runtime/src/studio-room.tspublishes the cycle-0 origin on transport changes and periodically, from the engine'scurrent_beatcorrelated with the AudioContext time the worklet stamped on the reply.song-player.tsaudioTime/alignBeatOrigin(localOrigin)reorigins the transport (only past a 30 ms drift threshold);room-view.tsmaps eachclockframe and aligns while playing. - Rooms in the Rust workbenches — the shell-agnostic seams live in
ui/src/bridge.rs:join_room(server, room, name, token, RoomRole),room_can_host,room_status→RoomStatus(peers,editing/hosting, host beat, clock offset + RTT, y-awareness presences with carets as BYTE offsets, and anerrorfor a refused join),room_sync(local) -> remote(the co-edit pump, which also carries a listener's snapshot source),room_caret,align_to_host,set_follow_host,set_peer_role,request_room_edit(a listener asking for a pen —ASK_EDIT_FILE),room_undo/room_redo.RoomStatusalso carriesroom(which room this client is in — the header chip and the room tab name it, and a?room=link never touches the dialog's own field) andrelay_open(socket open, relay unanswered — the join dialog's second progress state).ui/src/app/room.rsowns the dialog (roster with live roles + grant/revoke, host-beat + align, drift light, follow-host, freeze to paste, leave) and paints remote carets in the editor; joining any way armsroom_pushed_revwith the buffer's current revision, so a joiner never splices its own buffer into the room's document. That gate is a buffer REVISION, not a timestamp:last_editand the push marker are both stamped with the frame time, so a keystroke landing in the same frame as a push compared equal and was silently never shared — the tail of a typing burst stayed missing on every other peer until the next keystroke (ui/tests/rooms.rs).- Desktop (
app-native/src/collab.rs): a worker thread owns the socket and ayrs::Doc, with hand-rolled lib0 varint framing over the JSON{t:"sync"|"awareness", d}frames and carets as yrs sticky indices. Joins as editor or audience. - Browser (
runtime/src/studio-room.ts+runtime/public/studio-host.js, injected byindex.html): the PAGE owns the socket and the Yjs doc — wasm32 has no threads — reusingroom.ts+coedit.tsrather than a second client.StudioRoomjoins asaudience(a plainRoomConnection),editororhost(aYRoomProviderwith that role, which may also publish snapshot/transport/clock). Yjs indexes UTF-16 and the Rust editor speaks bytes, sobyteToIndex/indexToByteconvert every caret. The host publishes its cycle-0 origin from the engine'scurrent_beatcorrelated with the AudioContext time the worklet stamped on the reply (ct); a listener maps a host beat onto a known engine sample and callsreorigin_beat_at_sample— the same phase-lock the desktop does, past the same 30 ms threshold.?room=<id>listens (&host=1hosts),?edit=<id>co-edits. - One room (2026-08-05): the relay accepts
snapshotfrom any write role and the clients elect who sends it —publisherId(roster), the host if connected else the lowest editor id, mirrored identically inweb/src/room-protocol.ts,runtime/src/room.tsandapp-native/src/collab.rs. The audience is still fed a materialized snapshot, never the CRDT, which is what keeps the frozenroom.htmlpage working (pinned byroom.test.ts's legacy-compatibility block). - Live roles:
{t:"role", peer, role}, host-only,editor⇄audience. Enforced in the reducer off the LIVE roster, so a revoked peer'ssyncframes die at the relay;room-do.tsre-stamps socket attachments so a hibernation wake cannot restore the scope. - Multi-buffer co-edit (2026-08-05): one CRDT document per module file, keyed as the import tabs are. Frames carry
f— and the ENTRY buffer is the ABSENCE of it, which is what keeps a single-buffer peer (the frozenroom.html, the legacyyCollabbinding, an older desktop build) converging the song unchanged. A peer declares what it holds with{t:"files", open}(per-file presence on the roster), and a non-entry frame is ferried ONLY to the peers that declared that file, so another file's Yjs update can never land in a single-buffer peer's one document. Per-file: carets (each file has its own awareness table, painted only in the file you are viewing), undo (the focused file's manager), and the co-edit pump's revision gate. - Per-FILE write scopes:
{t:"scope", peer, file, allowed}— a SEPARATE message fromrole, because{t:"role", …, file}would read on every older client as a whole-peer demotion. A deny list (Peer.denied), so the default is unchanged: an editor writes every file. Same enforcement and the same attachment re-stamping asrole. Regaining a pen RE-OFFERS that document — the frames dropped while it was gone would otherwise leave the file forked (Yjs holds the next update as pending against one nobody has). - The closure travels: the elected publisher's snapshot carries
importsalongsidesource, and freeze-to-paste carriesimportedSources, because a listener holds no documents and cannot ask anyone for a module. Verified bynpm run test:rooms-files(runtime/scripts/room-files-relay-test.mjs) against a real DO. - Collaborative undo:
Editor::delegated_historyroutes ⌘Z to the room while co-editing. Desktop: a yrsUndoManagerwith remote applies undertransact_mut_with("remote"). Browser: aY.UndoManagerwithtrackedOrigins: {"local"}and local edits tagged to match. - Phase-only alignment:
volta_ui::bridge::phase_align_beat/runtime/src/room.tsphaseAlignBeat— for an ARRANGED song the re-origin corrects only the sub-cycle phase (the scene event owns the section); a bare track-set still aligns absolutely. The engine's per-block anchor gained the transport beat (sample_wall_beat_ref) so the local half is correlated rather than a frame stale. - Drift light:
RoomStatus::drift_light()over the beat-domain reading, with the offset in ms and "aligned N s ago". Never analyser RMS (§0.2). - The two links (W4, 2026-09-21): Copy listen link (
/r/<id>— "Opens a page that plays the jam — anyone can listen, no account needed, and they cannot type") and Copy edit link (/?edit=<id>&relay=<origin>— "Opens this room in the workbench — a signed-in friend lands in your peer list asking to edit"). They were "copy invite" and "copy co-edit link" side by side, with nothing saying what differed. - The peer list's verbs (W10): Allow ("Let this person edit the song") and Stop editing ("Stop this person editing (they keep listening)"), in place of grant/revoke and their tooltips about pens and the relay.
- The readouts (W3): "Transport: yours" for the host (hover: "Everyone in the room follows your play/stop and tempo"), "Host's transport: stopped", "The host left — nobody is driving the transport"; the relay-clock pair (
sync · offset … · rtt …) and the room id sit behind a details disclosure, because they are two numbers nobody can act on. - The join form (2026-09-02 live session): server / room / name, and NO token field — a bearer credential is not a form field. The form states the account instead ("signed in as @login"; signed out, it offers Sign in with GitHub / Discord and adopts the session when the sign-in lands) and offers one checkbox, listen only, which withholds the session for that join (
RoomDialog::effective_token, so unticking gives the pen back without signing in again). The room field opens on a real name (suggested_room_name:<login>-jam); hints are painted in the dim lab colour, and a disabledjoincarries its reason as disabled-hover text. Pasted room links still resolve throughparse_room_link. - Joining leaves a lesson first: a LEARN lesson or the Structure Lab BORROWS the editor buffer, so joining over one dropped the host's song into the lesson while the panel went on presenting it — and its goals ticked themselves against a stranger's code. The join runs the ordinary tutorial exit (
takeover()→toggle), restoring the suspended song, beforejoin_room. - Follow host catches up: switching follow ON applies the host's CURRENT transport, not just its next button press. The relay retains the room's last transport and hands it to every joiner in
hello;StudioRoomkeeps it (lastTransport, replayed bysetFollow) and so does the desktop (host_transport+follow_replayinapply_follow, which also now readshello.transport— it used to ignore it).RoomStatus::host_playingrides the same fact, and the dialog shows "The host is playing — tick follow to hear it in time" to an editor who has not followed. Before this a peer that ticked follow at bar 57 of a live jam sat stopped at bar 1 with a red drift light. Since 2026-09-21 follow starts ON for every role but the host (W3), so that cue is for a peer that deliberately unticked it. - Freeze to paste:
CommunityQuery::FreezeRoom→POST /api/pastewithcoauthors, through the community panel (which owns the onlycommunity_polldrain). Backend: thepaste_coauthorstable,migrations/0001_paste_coauthors.sql, and a "by X with Y and Z" byline. Credit, not ownership — the paste still has one owner. The button says Save this jam as a song… since 2026-09-21 (W4) and is SECONDARY, under the line "Publishes the room's current song to the feed and credits everyone who edited."; the credit list is de-duplicated by account (W12 — two peers of one person read as "credits devuser, devuser"). - Link this room to my latest published song (named "attach to my latest paste" until 2026-09-21, W4): one chip beside the freeze controls while hosting (signed-in only).
CommunityQuery::Profile→ take the newest paste →CommunityQuery::AttachLive→POST /api/paste/:id/live, so that song page grows a "listen live" chip and the front page lists the jam. Two hops, one gesture — "my latest paste" is what a performer means when they want the audience link to point somewhere. It rides the community panel for the same reason the freeze does (onecommunity_polldrain). - Voice: seams
voice_status→VoiceStatus,set_voice_enabled,set_voice_talking,set_voice_muted,set_voice_duck(on, depth_db)— all defaulted, so a shell without voice (the desktop) implements nothing and the dialog shows the gap. See "Voice chat" below. - Audience voice:
VoiceStatus.cast: Option<CastStatus>andVoiceStatus.talkersride the same status push (one seam, so the cast can never arrive out of order with the roster it refers to), plusset_voice_listen_volume/set_voice_listen_muted— defaulted, so a shell that cannot play a cast implements nothing. See "Audience voice". Verified byui/tests/rooms.rs(the shell-agnostic seam, driven by real typing through kittest: every keystroke reaches the room, a listener never pushes, the role labels match the relay's, the roster's grant/revoke chips address the right peers, only a host may freeze, the drift light's state mapping, and ⌘Z delegating in a room but not outside one),runtime/src/studio-room.test.ts(unit, fake relay — including publisher hand-over when the host leaves, a revoked editor ceasing to converge, and A-undoes-only-A's-text),web/test/room-protocol.test.ts(the reducer's fail-closed rules) andweb/test/coauthors.test.ts, plus two headless drives againstwrangler dev:runtime/scripts/studio-rooms-browser-test.mjs(three browser workbenches — host, listener, co-editor — asserting snapshot delivery, transport follow, beat-lock on the SYMBOLIC clock, co-edit convergence and carets, one-room roster roles + a single elected publisher, undo converging, a phase-only align that closes a 0.9-beat grid error while preserving a 16-bar section gap, a revoked peer's edits ceasing to converge, and a freeze read back throughGET /api/paste/:idwith its byline) andruntime/scripts/room-native-interop-browser.mjs(browser workbench ↔ the desktop client'se2e_web_interop). A third,runtime/scripts/room-files-relay-test.mjs(npm run test:rooms-files), points node clients speaking the WIRE PROTOCOL at the DO for the things only true at the relay: two editors converging on different FILES at once, a per-file revoke refused there (asserted on a raw socket — a frame that never arrives — and non-vacuously), a legacy-shaped peer still converging the entry buffer while being handed no other file's traffic, carets per file, the audience's import closure, and the freeze'simportedSources.VOLTA_ROOMS_FILES_NATIVE=1adds the desktop client'se2e_web_interop_filesagainst a real y-protocols peer.
- Desktop (
- Voice chat (Stage 6) —
runtime/src/voice.ts(+voice.test.ts).VoiceMeshowns oneRTCPeerConnectionper talker;webAudioIo(ctx, musicGain)is the browser's audio plumbing behind aVoiceIoseam, so the mesh's policy unit-tests in node with fakes. The pieces:- Signaling rides the room's existing socket:
{t:"signal", to, d}→{t:"signal", from, d}delivered to one peer (room.tssendSignal/onSignal,room-protocol.ts's{to:"peer", id}effect,room-do.ts's attachment lookup).dis a string the relay never parses; media never touches it. - Discovery rides presence:
{t:"presence", voice}→Peer.voiceon the roster. That is also how the desktop knows who is talking. - Glare is solved by arithmetic: the LOWER relay id offers (
offersTo), so exactly one offer crosses each pair with nothing to negotiate. ICE candidates that arrive before their description are queued and flushed (Session.pending). - Scope:
isVoiceEligible— host/editor only. The relay enforces the same rule on both ends of asignal, and demoting a peer to audience drops it out of the mesh (a revoked pen is a revoked microphone). - The ceiling is
MAX_MESH_TALKERS = 5, enforced as a refusal with a message; the mic is never opened, and the refusal lifts when a talker leaves. - Levels + speaking come from an
AnalyserNodeper remote stream (RMS,SPEAK_LEVELwith aSPEAK_HOLD_MShold), polled everyVOICE_POLL_MS. Never relayed. - Ducking:
duckGainFor(a proportional curve between two thresholds) throughDuckFollower(one-pole, 20 ms attack / 350 ms release) onto the page'smusicGain— aGainNodestudio-host.jsinserts between the engine worklet andctx.destination. Remote voice only. Depth + enable are dialog controls; the live dip is shown in dB. - ICE servers:
DEFAULT_ICE_SERVERSis public STUN;?ice=<json array>on the workbench URL supplies TURN. Deployment-time knob, not built infrastructure. - Transmit is a gain ramp on the mic path (
MicHandle.setGate), nottrack.enabled, so push-to-talk does not click; the meter is read BEFORE the gate so it moves while you decide to press talk. - The dialog (
ui/src/app/roomvoice.rsvoice_controls): join, talk (held) + latch, mute, duck + depth, a mic meter, and per-peer mic dots with level bars in the roster. A shell with novoice_statusshows "voice · N in the call — browser-only for now"; a listener is told "listeners hear the jam, not the chat". Verified byvoice.test.ts(20 cases: glare, eligibility, the ducking curve and the follower's attack/release asymmetry, the ceiling refusal, a refused mic, early ICE, teardown), fourui/tests/rooms.rscases, sixweb/test/room-protocol.test.tscases (addressed routing, no broadcast leak, the write-role scope on both ends, revoke-drops-voice, presence scoping, fail-closed decode), tworuntime/src/room.test.tslegacy-compat cases, and the headless driveruntime/scripts/studio-voice-browser-test.mjs(npm run test:voice): two workbenches on Chrome's fake capture device reach ICEconnected, the receiver measures a level off the mesh with its speaking dot lit, the music gain measurably dips and recovers, and a raw handler-less listener socket proves the relay never broadcasts asignal.
- Signaling rides the room's existing socket:
- Audience voice (Stage 6.5, 2026-08-06) —
runtime/src/voicecast.ts(+voicecast.test.ts). The audience HEARS the talkers, with no SFU and no new infrastructure: the elected talker mixes the call it is already receiving, encodes it ONCE with WebCodecs, and the relay fans that single stream out. Upload is O(1) in listeners; the mesh is untouched.- The wire:
{t:"vmix", d}in both directions — base64 of a 7-byte header (version+flags,sequ16,tMsu32) plus Opus, with the decoder config folded in every ~2 s so a mid-cast joiner configures from the next packet.dis opaque: the relay size-caps it (MAX_VMIX_BYTES= 4 KB), rate-limits the socket (VmixRateLimiter, 80 packets/s and 24 KB/s with burst), and routes. It never parses it. - Two presence flags:
cast(this peer is encoding the mix — only a write role that is IN the call may claim it) andhear(this listener can decode it and wants it — audience-only). Both ride{t:"presence", …}ontoPeer, likevoice. - The election is
voicePublisherId— the host if it is in the CALL, else the lowest editor id in it,nullwhen nobody is talking. Mirrored inweb/src/room-protocol.tsandruntime/src/room.ts. Deliberately not §5'spublisherId: the snapshot publisher may not be in the call, and a peer that cannot hear it cannot mix it. A hand-over needs NO message — the roster that removes one publisher names the next. - The relay refuses
vmixfrom anyone but the elected publisher. Stricter than thesnapshotrule on purpose: a late snapshot is stale state, but two interleaved Opus streams are noise. - Routing + the cap:
voiceListenerIds— audience peers withhear, sorted by id, truncated atMAX_VOICE_LISTENERS = 24. Pure, so a client finds ITSELF in the list to know whether it is inside the cap; the refusal past it is local ("the chat is full — you still hear the jam") and needs no frame. Write roles never receivevmix(they are in the mesh; the mix on top would double every voice). hearis the compatibility rule, not just a bandwidth one: a client that predates this never announces it, so the relay never sends it a byte. The frozenroom.htmlaudience is untouched — same discipline as multi-file co-edit'sfilesdeclaration.- Publisher:
VoiceIo.openMixsums the remote bus + the POST-gate mic into a MONOMediaStreamDestination, read asAudioDatathroughMediaStreamTrackProcessor;VoiceCastPublisherconfigures oneAudioEncoder(Opus,CAST_BITRATE24 kbps,CAST_FRAME_MS60, in-band FEC) and packs each chunk. It starts and stops purely off the roster — elected, and someone listening — so a room with nobody in the call, or nobody listening, sends nothing. - Listener:
JitterBuffer(target 3 packets ≈ 180 ms, wrap-aware sequencing, late-drop, gap-skip, symmetric reset on a hand-over) →AudioDecoder→VoiceIo.castOut, a gain stage with the LISTENER's own mute + volume, scheduled onto a playout cursor that re-anchors when it falls behind the clock. The listener's music ducks under the chat, sameduckGainFor/DuckFolloweras a talker's — driven off the DECODED SAMPLES rather than anAnalyserNode, which makes it exact and makes it assertable without a running audio clock. - Degradation: no
AudioEncoder/MediaStreamTrackProcessor⇒ never casts; noAudioDecoder⇒ never announceshear. Both say so in the dialog rather than showing dead controls. - The dialog (
ui/src/app/roomvoice.rs): a listener getslisten_controls— "voice · listening (N talking)" with a mute and a volume, or the capped refusal, or "this page cannot play the chat"; a talker'svoice_controlsgains a cast line ("you are mixing the chat for N listener(s)"). Seams:set_voice_listen_volume/set_voice_listen_muted(defaulted),VoiceStatus.cast: Option<CastStatus>andVoiceStatus.talkers. - Cost: ~36 kbps per listener on the wire (24 kbps Opus + a 7-byte header + base64's 33% + the JSON envelope; ~46 kbps if a browser ignores the frame-duration hint and encodes 20 ms frames). At the 24-listener cap that is ~0.9 Mbit/s of DO egress. The MUSIC is uncapped and unaffected — it is symbolic, rendered locally (§0.2).
- The desktop stays out of it entirely: it recognises
vmixand ignores it (app-native/src/collab.rs), never announceshear, and readscast/hearoff the roster. Verified byvoicecast.test.ts(24 cases: the codec round-trip and its fail-closed refusals, the pinned header size the cost math rests on, wrap-aware sequencing, the jitter buffer's reorder/late-drop/gap-skip/ hand-over-reset/overflow rules, the publisher's roster-driven start-stop, mono config, config refresh,AudioDatarelease and its degradation path, and the listener's decode, duck, local mute, cap awareness and re-configure-on-hand-over), 10web/test/room-protocol.test.tscases (election, audience-ward routing, editors never, the elected-publisher refusal, the deterministic cap, both presence scopes, the role sweep, fail-closed decode, and the rate cap against a REAL cast's rate), threeruntime/src/room.test.tscases (the wire shapes, and the frozen listener both never asking and ignoring), sixui/tests/rooms.rscases, and two drives: runtime/scripts/room-voicecast-relay-test.mjs(npm run test:voicecast) — raw node sockets against a live Durable Object, no browser and no audio anywhere: the mix reaches only the peers that asked, a legacy-shaped listener gets the room and not one packet (non-vacuously), the election moves by itself on a hand-over, packets arrive byte-identical and in order, and both caps bite without breaking the room.runtime/scripts/studio-voicecast-browser-test.mjs(npm run test:voicecast-browser) — two real workbenches with real WebCodecs. It substitutes ONLY the mix source (injectedAudioData, which needs no audio clock) and keeps the whole encode → relay → de-jitter → decode path shipped. It probes the audio clock first and reports the audibility leg as UNVERIFIED rather than failed when the clock is not running.
- The wire:
web/src/challenge-page.ts+ thechallengestable — the server-rendered challenge page; the feed + challenge handlers live inweb/src/handlers.ts, the trending re-rank is a pure function there.web/src/{session,identity,handle,accounts,auth,profile-page}.ts+users/user_identities/likestables — Stage 4, multi-provider.session.tsis the HMAC token codec (testable in node) and owns the payload version;identity.tsis the injected provider registry (githubIdentity,discordIdentity,PROVIDERS);handle.tsis the pure handle-claim + collision ladder and the written argument for why a handle is immutable;accounts.tsis the seam holding every account rule (resolve-an-identity-to-a-user, link, unlink) so the endpoints stay thin;auth.tsis login/callback/dev/link/unlink/me plus the/auth/signinchooser page;profile-page.tsrenders/@userwith provider badges. Runtime:runtime/src/auth.ts(session capture from the callback fragment,signIn(provider?)/fetchMe/fetchAccount) andruntime/public/studio-host.js, which owns sign-in and the token-carrying publish for the Rust workbench's browser shell. Covered byweb/test/auth.test.tsand the 42-caseweb/test/providers.test.ts(both stores + the migration), plus theruntime/scripts/auth-providers-e2e.mjsdrive against a live Worker.web/src/registry.ts+pack-page.ts+ thepacks/pack_versionstables — Stage 3.registry.tsis the spec grammar + sha-256 content address; the publish/resolve/browse handlers live inhandlers.ts(publish is authed, idempotent on identical content, and refreshes description/license/tags per publish); version sources live in R2 at content-addressedpackblob/<hash>keys (entry, modules, samples; the version row'smanifestnames them);pack-page.tsrenders/packsand/p/@user/name. Covered byweb/test/registry.test.tsandweb/test/pack-files.test.ts(both stores over FakeD1).web/src/room-protocol.ts+room-do.ts+room-page.ts— Stage 5. TheRoomRelayDurable Object (room-do.ts, exported fromindex.ts) is a thin socket adapter over the pureroom-protocol.tsreducer (message types +RoomStatejoin/leave/reduce, host-only-event enforcement, and a fail-closeddecodeClientMsg— node-tested inroom-protocol.test.ts, no Workers runtime). For co-editing the reducer also ferries opaquesync/awarenessframes to the other editors (Stage 2) — routed by the frame's FILE KEY since multi-buffer co-edit, which is one string compare, so the DO stays a dumb Yjs relay with noyjsdependency in the Worker. It uses the WebSocket Hibernation API (acceptWebSocketwebSocket*handlers), rebuilds its roster from the live sockets on wake, persists the host's content to DO storage (a paused jam resumes), and verifies the host token viaverifySession.index.tsbindsROOMS+ branches WS upgrades (/api/room/:id/ws) to the DO ahead of the HTTP handlers;wrangler.tomldeclares the SQLite-backed DO + av1migration.room-page.tsrenders/r/:id(room-page.test.ts); the whole relay is exercised end-to-end over real WebSockets underwrangler dev.
- Backend (
web/): a Cloudflare Worker — seeweb/README.mdfor the API, setup, and the wire contract. Pure request logic (web/src/handlers.ts) over aPasteStoreseam (store.ts), so it unit-tests against an in-memory store with no Workers runtime; the production adapter (d1-store.ts) is D1 (paste metadata + remix tree) + R2 (the by-value{source, importedSources}blob).web/src/page.tsserver-renders the song page (HTML-escaped — the XSS boundary);web/src/share-link.tsreconstructs the#s=fragment in lockstep withruntime/src/share.tsso the embed/workbench links work unchanged (round-trip verified). Abuse guards (guards.ts) are injected. The same Worker also serves the hosted MCP endpoint,POST /api/mcp(stateless Streamable HTTP; a foreignOriginis refused unless listed inMCP_ALLOWED_ORIGINS) — see Use Volta from your own AI agent (MCP). - Dependency:
lz-string(URI-safe compress/decompress) — in bothruntime/andweb/(the song page reconstructs the same fragment).
Notes & limits#
- Imports by value. The
importsfield (the Ideas §10.3 lockfile-by-value) carries registry-pack sources since Stage 3: share links and pastes snapshot the song's pack closure, so a shared song renders without the recipient touching the registry — and keeps rendering identically after the pack republishes. Bundled stdlib stays out (the recipient already ships it), keeping URLs minimal. - Size. A ~5 KB song packs to ~1.5–2.5 KB of fragment; the button warns past ~8 K characters, where some chat apps truncate. That ceiling is exactly what Stage 2 pastes lift.
input. A song using live mic input shares fine but renders that source silent on the listener's machine (no microphone in their context unless they grant one) — the same offline-render caveat as WAV export.- Pastes are immutable. Editing then publishing again makes a new paste;
parent_idthreads the remix tree. The publish button sends the source plus any registry-pack sources by value (importedSources); bundled stdlib stays out. - Packs are one module each, owned, and CC0 by default. Publishing requires sign-in (
@login/…is the namespace; takedown needs an owner); multi-module ecosystems come from packs importing packs, not from a manifest. Versions are immutable monotonic integers (content-addressed — the resolve response carries the sha-256). The publish dialog carries a license picker (CC0-1.0 / MIT / CC-BY-4.0 over an editable field) and a tags field, and takes the description from the module's--header blurb;volta publish, demo songs on pack pages, and owner-only yank all ship (see Surface above). The remaining follow-up is an admin-forced takedown distinct from owner yank — tracked in Share music and collaborate §9 / Community Deploy Hardening §6. - The default registry base is
https://volta.fm/api(the future public instance) — until deployment, pointVOLTA_REGISTRY(CLI) /VITE_PASTE_API(workbench) atwrangler dev. - Deploying the Worker is
make deploy-worker(scripts/deploy/deploy-worker.sh):stagingis a workers.dev origin needing onlywrangler loginand two secrets;productionis thevolta.fmroute set inweb/wrangler.toml[env.production], which claims only the Worker's own paths so the static workbench keeps/,/reference/and/learn/(the server-rendered front page is also at/communityfor that reason). A split-origin deploy (site on the droplet, Worker on workers.dev) has to agree in three places —VITE_PASTE_APIat site build, the site's CSPconnect-src, and the Worker'sWORKBENCH_ORIGIN— all driven fromVOLTA_WORKER_ORIGINin.deploy.env.web/README.md§ Deploy is the runbook. - Deploying the public site is
make deploy-pages(scripts/deploy/deploy-pages.sh) to the Cloudflare Pages projectvoltaon the same zone. Pages refuses files over 25 MiB, so the set inweb/src/site-large.json(the UI wasm, everything under/models/) goes to thevolta-site-largeR2 bucket instead, and the production Worker serves it at the same URLs (web/src/site-large.ts, its own routes; a test pins routes to the list). An oversized file outside the set fails the deploy. Deploy the production Worker before the site. - Paste backend is local-first. The whole service runs and tests under
wrangler dev(Miniflare D1/R2) with no cloud account; deploy iswrangler deploy(web/README.md). Server-side wasm validation and syntax-highlighted song-page source are tracked follow-ups in Share music and collaborate. - og-card render is client-only and best-effort. The PNG is drawn on a canvas in the browser (the wavescape needs no playback); the server only stores and serves bytes. A render/upload failure leaves the paste intact (just no
og:image). - Rooms broadcast the performance, not the waveform. The host relays source + transport; each listener compiles and renders locally, so they hear the same notes at the same musical positions but not bit-identical PCM (native Web Audio DSP differs across browsers — Share music and collaborate §0.2). Listeners are read-only audience (render, don't edit), and they now beat-lock to the host: the host publishes its cycle-0 origin on the relay clock (with an NTP-min-RTT offset), and the listener maps it onto its own AudioContext and re-origins the transport (
reorigin(0, origin)— phase-locking the whole beat timeline, tempo map included; drift-corrected by the host's periodic re-broadcast + a 30 ms-threshold reorigin). A bare track-set (nosongblock) never emits play/scene, so listeners play all tracks (still clock-aligned). The relay + clock frames are verified end-to-end. The Rust workbench's path is now driven end-to-end too (studio-rooms-browser-test.mjs: three browser peers against a live relay, ~0–0.5 ms measured beat error, asserted on the symbolic clock rather than audio). The frozenroom.htmlaudience page's own align (room-view.ts→song-player.tsalignBeatOrigin) stays unit-tested but undriven; its client is pinned against the widened protocol byroom.test.ts, so the freeze is not a blind spot. - On an arranged song, "beat-locked" means PHASE-locked. Since 2026-08-05 the Rust shells re-origin a listener onto the host's phase within its own section rather than onto the host's absolute beat: the scene event is what lands the section, and the re-origin supplies the phase inside it (Share music and collaborate §9b). Two in-sync peers can therefore sit a whole number of cycles apart while sounding identical — so any check that compares absolute beats is asserting the wrong thing; compare the beat modulo the cycle. A bare track-set has no sections and still aligns absolutely, which was always right for it. The
room.htmlaudience view (room-view.ts) still does the absolute align — a frozen surface, correct for the un-arranged songs it was written against. - Rooms need sign-in and the backend. Hosting or co-editing requires a session (the DO rejects an unauthenticated host/editor), and the room surface is hidden without a configured backend. Listeners are anonymous. In the Rust workbench the desktop shell has its own room dialog (
ui/src/app/room.rsroom_window,EngineBridge::join_roomoverapp-native/src/collab.rs) — rooms are not hidden on the desktop, which this page used to claim. A room id is a bare relay key, not a paste id; freeze-to-paste writes a NEW paste rather than making the room one, and the other direction ("open as jam" on a song page, a public open-jams board) is still a follow-up. - Your role comes from your token. There is no listen-vs-co-edit mode switch any more (2026-08-05): anonymous joins are audience, a session token joins as editor, and "host this room" is a separate capability toggle for the one peer that drives transport + the clock. The host can move any peer between editor and audience live, and the relay enforces it — a revoked peer's edits are dropped there, not merely greyed out locally.
- Who can host. Both shells. The desktop host mirrors the browser's: it asks for
role=host, seeds an EMPTY room's document with its song (ToRoom::OpenFileswith the entry file, seed-if-empty past the sync gate), sendsplay/stop/scenefromStartAll/StopAll/a manualApplyScene(a walk's ownat_barsteps are not sent — every follower walks its own arrangement), and publishes its cycle-0 origin on the relay clock every 4 s and 250 ms after a transport or tempo change — the inverse of the follower'sroom_anchor, from the same audio-thread anchor. A contested host request degrades at the relay; the roster's echo is whathostingfollows, and a degraded desktop sends no host frames. A host-less room still feeds its listeners (the clients elect the lowest-id editor as publisher) but publishes no clock, so its listeners follow the document without beat-locking. The browser's room dialog is reached from the header chip or a?room=/?edit=link; the desktop's from the header chip orvolta-studio --join <link>/--host <room>. Either way the dialog back-fills its room field from the room it is IN, so its copied links name that room. - Joining to edit asks for you. A peer that joined intending to edit (a co-edit link,
--joinwith a session, a contested host request) and was seated as a listener in an owned room sends the!ask-editmarker on its firsthello— both shells — so it lands in the host's list as wants to edit · Allow without hunting for the button. The host's list offers no Allow beside an anonymous peer: the roster'sanonflag (the relay knows who joined without a session) marks one, and the listen page has no editor to hold a pen in. - A late host seed broadcasts. A host whose
hellonamed another writer waits for the room's document, then seeds it at the sync gate if it is still empty — as an UPDATE (seedIfEmpty(…, broadcast)), because the writers already there handshook before the seed existed and will never ask for it. Otherwise a guest who opened the link before the host sits on an empty document while the host's keystrokes pile up unapplicable on top of a seed it never received. - A browser room carries its import closure. The
RoomSnapshotwire type carriesimportsandmacros; the browser host fillsimportsfrom the program it last actually installed (installedSnapshotOfinruntime/src/studio-room.ts), so a listener that holds no documents can still render a multi-file song.macrosstay empty — live knob overrides are not published. A co-editor's engine is also never auto-loaded from the host's snapshot: co-editing means each peer compiles the converged source when it chooses to (⌘⏎), which is what the desktop does too. - Co-editing is a dumb-relay CRDT. The relay never holds the Yjs document — convergence + late-join run client-side via y-protocols, and the doc is not persisted (a co-edit session lives only while ≥1 editor is connected; if all leave, it's gone). In the Rust shells ⌘Z is collaborative: it pops the room's own undo manager, which tracks local-origin transactions only, so it takes back your line and never a peer's — and, because the undo is itself a CRDT edit, it reverts on their screens too. When you have nothing of your own left to undo, nothing happens; there is deliberately no fall-back to local history, which is what could eat someone else's work. Entry-buffer-only "legacy-shaped" peers remain a protocol reality via
room.html, and are still pinned by the relay tests. - Voice is browser-only, and TALKING is among the pens.
volta-studiohas no WebRTC stack and no WebCodecs: it reads the roster'svoice/castflags and says "voice · N in the call — browser-only for now", and it recognises-and-ignores bothsignalandvmixframes so the widened protocol cannot upset it. Because it never announcesvoicenobody offers to it, and because it never announceshearthe relay never routes it a media packet. Within the browser, the MICROPHONE is scoped to peers holding a pen; listening is not — since 2026-08-06 the audience hears the chat through one relayed mix (see "Audience voice"). The practical mesh ceiling is 5 simultaneous talkers, refused with a message rather than a failure, and it is the one thing still waiting on an SFU. TURN is a config knob (?ice=), not a provisioned service, so peers behind symmetric NATs may fail to connect on the default STUN-only config — the peer connection reportsfailedand the dialog says a TURN relay may be needed. - Audience voice needs WebCodecs, and is capped. A publisher needs
AudioEncoder+MediaStreamTrackProcessorand a listener needsAudioDecoder— Chrome-shaped today. Without them the page never casts / never announceshear, and says so in the dialog; the jam is unaffected. Voice for listeners is capped at 24 per room (~36 kbps each through the Durable Object); past it a listener gets "the chat is full — you still hear the jam". The MUSIC is uncapped and always will be — it is symbolic and everyone renders it locally. There is no recording, no transcript and no per-talker volumes: the mix is one stream, and separating it again is exactly what made it cheap. - Remaining pieces — docs "open in workbench" links (the last Stage 1 item), >5 simultaneous talkers (an SFU), a provisioned TURN service, "open as jam" + an open-jams board, and the Stage 3 / Stage 7 tails are tracked in Share music and collaborate. The big one is Share music and collaborate §10: none of this is deployed.