VOLTAreference

Reference / Connect and share

Share music and collaborate

Connect and share

Open in the workbench →
On this page · 5 sections

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 /api in 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 by web/src/song-facts.ts, because a paste row stores neither. A remix says "remix of &lt;title&gt; by @handle", linking both. Counts read as English ("1 view · 2 plays"), through count/plural in web/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 records parent either way. Emits og:image (→ /og/:id.png) once a card exists; og:description is the song's own description when it has one.
  • og-card — GET /og/:id.png serves 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 second SongPlayer deck, 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 in runtime/src/dj.ts (unit-tested); the conductor in radio.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 a pastes query, 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 via POST /api/challenge (Bearer ADMIN_TOKEN).
  • Code golf — a challenge created with golf: true ranks 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 with wc -c on 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 from byte_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 (default github), /auth/callback, /auth/dev?as=&provider= (local), GET|POST /auth/link?provider= and POST /auth/unlink?provider= (authed — attach/detach a provider on the CURRENT account), GET /api/me (user + providers + a one-time handleNote; plus devAuth: true, signed in or out, when this Worker runs with DEV_AUTH — loopback only, and the login wall reads it to label its provider buttons honestly). Session = an Authorization: Bearer token from the callback fragment, kept in localStorage["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.html and its static twin public/studio.html) boot through ONE module, public/studio-boot.js, which runs public/studio-gate.js — served verbatim like studio-host.js, contract pinned by runtime/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/me vouches for; an automated browser (navigator.webdriver — every headless drive in runtime/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, with next= 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/me reports devAuth, 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.html and radio.html remain public — they are the outward funnel.
  • Usage metrics (2026-08-25) — POST /api/events takes 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 into usage_daily (who, unix-day, kind) — the ai_usage idiom: counts only, no raw events, no sub-day timestamps; signed-in users keyed u:<uid>, anonymous surfaces ip:<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.ts beacons from the workbench page — boot + signin after the gate, a 5-minute visible-tab heartbeat (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 the room vite 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 browser WebSocket can't set Authorization); 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::classifyRelayOrigin in the browser (a write-role join to an untrusted origin is refused outright, not silently downgraded to audience) and ui/src/app/room.rs::trusted_relay + token_may_go_to on the desktop (a pasted https://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 from localStorage, 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/document answers 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 through live.
  • 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 frozen room.html audience. Adding a frame means teaching all of them — the desktop's may recognise and ignore one, but deliberately.

    FrameCarriesWho may sendRelayed to
    snapshot{source, seed?, imports?, macros?} — the materialized songany write role (clients elect the publisher)others
    scene / play / stopthe transporthostothers
    clock{originServerTime, bpm, barSeconds, songBarBeats}hostothers
    ping → pongthe NTP-style offset probeanyonethe sender
    presence{playing?, voice?, cast?, hear?}anyone (claims are scoped)all, as roster
    sync / awarenessopaque Yjs blobs, base64, per file key fwrite roles with that file's penthe peers holding f
    fileswhich module files this peer holdsanyoneall, as roster
    role / scopea peer's pen, whole or per filehostall
    signalan opaque WebRTC handshake for ONE peerwrite rolesthat peer
    vmixone opaque Opus packet of the voice mixthe elected voice publisherlisteners that announced hear
    macro{slot?, name, value, ramp?} — one knob movewrite roles; a slot's macro needs that slot's claimothers
    slot.add / slot.removeslot id, ref, ownerhost for anyone; a write role for its ownall
    slot.programslot id, source, opaque state / ctxthe slot's owner or co-playersothers
    slot.stateper-path ops with Lamport stampsthe slot's owner or co-playersothers (accepted ops only)
    slot.notenoteOn/noteOff/oneShot with a beat stampthe slot's owner or co-playersothers
    slot.runningthe slot's running track setthe slot's ownerothers
    slot.coplaya slot's co-play allow listthe slot's owner or the hostall
    hello / rosterwho is here, their roles, and slot ownershiprelaythe joiner / all
  • Macro sharing — RoomSnapshot has declared a macros map 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's set_macro (or ramp_macro, when the move carries a ramp in 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's hello carries the knob where the ROOM left it, not where the published snapshot did; a fresh snapshot supersedes 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/scope scope a peer's pen over FILES; the slot.* 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.add that id takes the seat — which is what makes a reopened room playable. A room that never adds a slot is unchanged on the wire: hello and roster omit slots entirely, and none of the new arms is reachable.
  • Who gets a pen — asking for role=editor is 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 on pagehide — instant, and the normal path. As the backstop the reducer keeps a seen stamp per peer: a peer that has proven it pings (ping, every 5 s in every client) is enrolled, and sweep drops any enrolled peer silent for PEER_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 every PEER_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 in web/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.destroy removed only our own client from each file's awareness table (and Awareness.destroy removes nobody), while StudioRoom.close set closed before 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 joinRoom was not in studio-host.js's PAGE_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, like publish beside it. The dialog also shows the connect: pressing join replaces the form with Connecting…, then Waiting for the relay… once the socket is open and unanswered (relayOpen in the status blob — open AND not yet hello), 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 reading untitled-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 setTimeout to 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 while document.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 desktop volta-sources resolves the spec LAST, after every local path, and caches it under ~/.volta/registry with a .sha256 sidecar (registry/src/lib.rs; VOLTA_REGISTRY overrides the API base, VOLTA_CACHE_DIR the 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". The libvolta, 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.rs is the authority; runtime/public/studio-module-sources.js mirrors it. A response must declare hash and packHash (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] (and registry::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|recent searches (q substring over name/description/tags, tag exact, ANDed); GET /api/pack/:owner/:name → { pack, versions } with yank flags.
  • Publish rules — POST /api/pack needs a license (CC0-1.0, MIT, CC-BY-4.0, CC-BY-SA-4.0, custom:<text>), refuses a mismatched sourceHash/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 as latest and leaves search/browse, but a pinned @user/name@N import 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 no latest at all (/api/registry/@u/n 404s and says why) and drops out of browse, while staying on the owner's profile so they can undo it. latestVersion is the monotonic mint counter; latestLiveVersion is what an unpinned import gets. The resolvers needed no change — latest selection 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) and GET /p/@user/name (import line, demo player, latest source, version list with yanked versions dimmed).
  • CLI resolve — volta song.volta resolves registry imports too: cache at ~/.volta/registry/<owner>/<name>@<v>.volta, VOLTA_REGISTRY overrides the API base (e.g. http://localhost:8787/api), VOLTA_CACHE_DIR the cache; offline "latest" errors and names the pin that would work, unless VOLTA_REGISTRY_OFFLINE=1 opts into the newest-cached substitution. A local <spec>.volta file 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>.sha256 sidecar; 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 --name or the kebab-cased file stem, the description from the module's -- header blurb, and the license from --license (default CC0-1.0). Republishing identical bytes prints unchanged and mints nothing, so a CI step can run on every commit. --dry-run shows 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 than VOLTA_REGISTRY points at, it names both rather than behaving as though nobody is signed in. The store lives in registry/src/session.rs (shared with app-native), the wire half in registry/src/publish.rs, the command in cli/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 or no description, a relative date (3d ago — from createdAt against the shell's wall_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). PasteSummary carries 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 synchronous publish_paste or the browser's publish_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 manual reload click.

    • packs — search + installs/recent sort over GET /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) and GET /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 @N still 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 the installs / recent sort chips are quiet navigation beside a publish… 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 says Radio 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 says radio 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 a radio · … toast, and the panel counting next in 44s under 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 @handle field over GET /api/user/:login (the JSON twin of /@login, added for this), and a profile header: the handle, the display name, N songs · M packs, and first 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 a sign 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 — @handle beside an account ▾ 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/me reports devAuth (a loopback Worker with DEV_AUTH=1), every sign-in entry reads GitHub · 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's dev_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 own overlay= token) and, once /api/me confirms the identity, restores it with shell_command {"type":"setView"} and says Signed in as @you in the toast strip. The marker — not the #session= fragment — is what says a load is a return trip: studio-gate.js runs 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 sends Authorization: Bearer when 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/paste accepts title, source, importedSources, parent, updates and coauthors and 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 — a TypeError: 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 sentence Remixing '<title>' by @<author> — publish to post your version through the shell-notice seam. The panel shows it as a toast AND keeps the lineage for the publish dialog (REMIX_NOTICE_PREFIX is 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/device in any browser; the CLI's poll receives a fresh session in ~/.volta/session.json.
  • Registry imports — volta-studio resolves import "@user/pack[@N]" through volta-registry (registry/, extracted from the CLI's old resolver so the CLI and the workbench share one implementation and one ~/.volta/registry cache). A local <spec>.volta beside the song still shadows the registry; VOLTA_REGISTRY still 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:

  1. bind a one-shot listener on an ephemeral loopback port;
  2. open {server}/auth/login?provider=<p>&next=http://127.0.0.1:<port>/cb in the system browser (the user's provider session and 2FA live there, not in our process);
  3. 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 a communityServer query with it, derived from VITE_PASTE_API, instead of the wasm reporting location.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 through apiBaseOf in studio-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 by studio-host.js even before the worklet is ready — they touch the network and localStorage, 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 storage event on volta:session and re-runs /api/me, pushing the answer back through push_community_auth — so the account chip and the token on the wire are always the same person. (runtime/src/studio-host-community.test.ts pins 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-argument community_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 in ui/tests/community.rs. One CommunityQuery per backend call; replies are the raw JSON body, correlated by the id community_start returned. 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 in ui/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-thread ureq client (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, and Studio.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 (/@login is 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/decodeShare over lz-string's URI-safe variant; buildShareUrl; readShareFragment (pure over a hash string) / readShareFromLocation. decodeShare fails closed — a malformed or newer-v blob returns null rather than a half-payload. Covered by share.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's macros are 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 its setPendingMacros rack-seeding were deleted 2026-08-09; see git history.)
    • runtime/src/modules.ts — ModuleLibrary.addSources merges import sources carried by value, without shadowing the recipient's shipped stdlib; registry-fetched packs land through the same door and report origin: "registry" in the module browser.
    • runtime/src/registry.ts (+registry.test.ts) — the pack-spec grammar (parsePackSpec/isPackSpec, /sub included), the wire types and publishPack. Resolution lives in runtime/public/studio-module-sources.js (createPackReader, gatherModuleSources, the lock and packHashOf), 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 (crate volta-registry, shared by volta-cli and volta-studio): same spec grammar and wire contract, ~/.volta/registry disk cache with sha-256 sidecars, ureq (never the compiler, which stays network-free). gather_imports in cli/src/main.rs tries a local <spec>.volta first, then the registry.
    • runtime/embed.html + runtime/src/embed.ts — the embed player. It builds the real RuntimeState with a stripped, mostly-no-op RuntimeDeps (getEditorSource returns the decoded share source), decodes the fragment, recompile()s, and on the play gesture warms assets then playSong. Only a module with no song block at all takes the start-every-track path now: the branch used to be sections.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 reads RuntimeState.masterAnalyser() → Mixer.masterAnalyser() (a browser-only accessor, not on the shared AudioRuntime interface — the native backend has no Web Audio analyser). Registered as the embed vite entry.
    • Video export (recordVideo, a MediaRecorder over 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 (like osc-test.html): edit a song, live-preview embed.html in an <iframe>, exercise macro restore and the size warning.
    • runtime/src/paste.ts (+paste.test.ts) — the paste client: publishPaste / fetchPaste / fetchRemixes / beaconPlay / uploadOgCard, the pasteApiBase() / pasteBackendConfigured() config. runtime/public/studio-host.js handles the ?remixOf= boot param (it names the buffer after the parent paste); the Rust workbench's own publish runs through CommunityQuery::Publish on the community bridge.
    • runtime/src/og-card.ts — renderOgCard(module, title) draws a 1200×630 PNG (title + the wavescape, reusing wavescapeRows exported from lens.ts) on a canvas and toBlob()s it. publishCurrentSong uploads 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 real RuntimeState, load/play/stop/ masterAnalyser; startScope for 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. play applies the song's opening scene and then arm_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 from setTimeouts 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's AR01 trailer through runtime/src/arrangement-frame.ts — the scene owning the bar, the edge riding it, bars-into-step, and the graph decision log — with LV01'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's ended flag (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 reporting playing after 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.onEnded is the page-side subscription (the embed player flips its button back to ▶ and says "ended"); native-runtime.ts's followWalk clears the running mirror on the same frame. A looping schedule and a songGraph never set it — neither has an end.

      Scenes with no arrange do not cycle. A song that declares scenes but 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 an arrange are a palette to launch by hand, not a form. To hear a song move on its own, give it an arrange (or a songGraph). runtime/scripts/arrangement-parity-test.mjs asserts 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: pulls fetchFeed from paste.ts, plays each track through a SongPlayer, auto-advances, bumps plays via beaconPlay. radio vite 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) + localOriginTime for later phase-alignment, roomWsUrl/newRoomId, joinRoom, and RoomHost (debounced + deduped snapshot pushes + transport). Wire types mirror web/src/room-protocol.ts (lockstep, like share.ts ↔ web/src/share-link.ts). runtime/src/studio-room.ts is the page-side client that drives it for the wasm workbench (host wiring, the ?room=/?edit= boot resume); SongPlayer gains playSongByName/selectSceneByName so 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 shared SongPlayer, loads each host snapshot (hot-swapping a running graph via §5.5's atomic swap), follows transport, and shows the roster + output scope. room vite entry.
    • runtime/src/coedit.ts (+coedit.test.ts) — CRDT co-editing (Stage 5 slice 2). YRoomProvider binds a shared Y.Text ("source") to a RoomConnection (role editor), running the y-protocols sync + awareness handshakes over the relay's opaque sync/awareness frames (base64 via lib0); it re-announces awareness when the roster grows so late joiners see present editors, and seedIfEmpty seeds 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.ts drives it for the wasm workbench: it joins as audience/editor/host, converts every caret between Yjs's UTF-16 indices and the Rust editor's byte offsets, and pumps the doc back through Studio.
    • Clock-sync (Stage 5 slice 3) — the beat-lock. room.ts ClockSync (NTP-min-RTT offset over ping/pong) + the inverse pair serverTimeForCtx (host publishes) / localOriginTime (audience maps back), round-trip unit-tested. RoomHost gains startPing/serverNow/pushClock; runtime/src/studio-room.ts publishes the cycle-0 origin on transport changes and periodically, from the engine's current_beat correlated with the AudioContext time the worklet stamped on the reply. song-player.ts audioTime/alignBeatOrigin(localOrigin) reorigins the transport (only past a 30 ms drift threshold); room-view.ts maps each clock frame 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 an error for 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. RoomStatus also carries room (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) and relay_open (socket open, relay unanswered — the join dialog's second progress state). ui/src/app/room.rs owns 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 arms room_pushed_rev with 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_edit and 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 a yrs::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 by index.html): the PAGE owns the socket and the Yjs doc — wasm32 has no threads — reusing room.ts + coedit.ts rather than a second client. StudioRoom joins as audience (a plain RoomConnection), editor or host (a YRoomProvider with that role, which may also publish snapshot/transport/clock). Yjs indexes UTF-16 and the Rust editor speaks bytes, so byteToIndex/indexToByte convert every caret. The host publishes its cycle-0 origin from the engine's current_beat correlated with the AudioContext time the worklet stamped on the reply (ct); a listener maps a host beat onto a known engine sample and calls reorigin_beat_at_sample — the same phase-lock the desktop does, past the same 30 ms threshold. ?room=<id> listens (&host=1 hosts), ?edit=<id> co-edits.
      • One room (2026-08-05): the relay accepts snapshot from any write role and the clients elect who sends it — publisherId(roster), the host if connected else the lowest editor id, mirrored identically in web/src/room-protocol.ts, runtime/src/room.ts and app-native/src/collab.rs. The audience is still fed a materialized snapshot, never the CRDT, which is what keeps the frozen room.html page working (pinned by room.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's sync frames die at the relay; room-do.ts re-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 frozen room.html, the legacy yCollab binding, 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 from role, 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 as role. 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 imports alongside source, and freeze-to-paste carries importedSources, because a listener holds no documents and cannot ask anyone for a module. Verified by npm run test:rooms-files (runtime/scripts/room-files-relay-test.mjs) against a real DO.
      • Collaborative undo: Editor::delegated_history routes ⌘Z to the room while co-editing. Desktop: a yrs UndoManager with remote applies under transact_mut_with("remote"). Browser: a Y.UndoManager with trackedOrigins: {"local"} and local edits tagged to match.
      • Phase-only alignment: volta_ui::bridge::phase_align_beat / runtime/src/room.ts phaseAlignBeat — 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 disabled join carries its reason as disabled-hover text. Pasted room links still resolve through parse_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, before join_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; StudioRoom keeps it (lastTransport, replayed by setFollow) and so does the desktop (host_transport + follow_replay in apply_follow, which also now reads hello.transport — it used to ignore it). RoomStatus::host_playing rides 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/paste with coauthors, through the community panel (which owns the only community_poll drain). Backend: the paste_coauthors table, 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 (one community_poll drain).
      • 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> and VoiceStatus.talkers ride the same status push (one seam, so the cast can never arrive out of order with the roster it refers to), plus set_voice_listen_volume / set_voice_listen_muted — defaulted, so a shell that cannot play a cast implements nothing. See "Audience voice". Verified by ui/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) and web/test/coauthors.test.ts, plus two headless drives against wrangler 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 through GET /api/paste/:id with its byline) and runtime/scripts/room-native-interop-browser.mjs (browser workbench ↔ the desktop client's e2e_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's importedSources. VOLTA_ROOMS_FILES_NATIVE=1 adds the desktop client's e2e_web_interop_files against a real y-protocols peer.
    • Voice chat (Stage 6) — runtime/src/voice.ts (+voice.test.ts). VoiceMesh owns one RTCPeerConnection per talker; webAudioIo(ctx, musicGain) is the browser's audio plumbing behind a VoiceIo seam, 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.ts sendSignal/onSignal, room-protocol.ts's {to:"peer", id} effect, room-do.ts's attachment lookup). d is a string the relay never parses; media never touches it.
      • Discovery rides presence: {t:"presence", voice} → Peer.voice on 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 a signal, 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 AnalyserNode per remote stream (RMS, SPEAK_LEVEL with a SPEAK_HOLD_MS hold), polled every VOICE_POLL_MS. Never relayed.
      • Ducking: duckGainFor (a proportional curve between two thresholds) through DuckFollower (one-pole, 20 ms attack / 350 ms release) onto the page's musicGain — a GainNode studio-host.js inserts between the engine worklet and ctx.destination. Remote voice only. Depth + enable are dialog controls; the live dip is shown in dB.
      • ICE servers: DEFAULT_ICE_SERVERS is 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), not track.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.rs voice_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 no voice_status shows "voice · N in the call — browser-only for now"; a listener is told "listeners hear the jam, not the chat". Verified by voice.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), four ui/tests/rooms.rs cases, six web/test/room-protocol.test.ts cases (addressed routing, no broadcast leak, the write-role scope on both ends, revoke-drops-voice, presence scoping, fail-closed decode), two runtime/src/room.test.ts legacy-compat cases, and the headless drive runtime/scripts/studio-voice-browser-test.mjs (npm run test:voice): two workbenches on Chrome's fake capture device reach ICE connected, 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 a signal.
    • 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, seq u16, tMs u32) plus Opus, with the decoder config folded in every ~2 s so a mid-cast joiner configures from the next packet. d is 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) and hear (this listener can decode it and wants it — audience-only). Both ride {t:"presence", …} onto Peer, like voice.
      • The election is voicePublisherId — the host if it is in the CALL, else the lowest editor id in it, null when nobody is talking. Mirrored in web/src/room-protocol.ts and runtime/src/room.ts. Deliberately not §5's publisherId: 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 vmix from anyone but the elected publisher. Stricter than the snapshot rule on purpose: a late snapshot is stale state, but two interleaved Opus streams are noise.
      • Routing + the cap: voiceListenerIds — audience peers with hear, sorted by id, truncated at MAX_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 receive vmix (they are in the mesh; the mix on top would double every voice).
      • hear is 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 frozen room.html audience is untouched — same discipline as multi-file co-edit's files declaration.
      • Publisher: VoiceIo.openMix sums the remote bus + the POST-gate mic into a MONO MediaStreamDestination, read as AudioData through MediaStreamTrackProcessor; VoiceCastPublisher configures one AudioEncoder (Opus, CAST_BITRATE 24 kbps, CAST_FRAME_MS 60, 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, same duckGainFor/DuckFollower as a talker's — driven off the DECODED SAMPLES rather than an AnalyserNode, which makes it exact and makes it assertable without a running audio clock.
      • Degradation: no AudioEncoder/MediaStreamTrackProcessor ⇒ never casts; no AudioDecoder ⇒ never announces hear. Both say so in the dialog rather than showing dead controls.
      • The dialog (ui/src/app/roomvoice.rs): a listener gets listen_controls — "voice · listening (N talking)" with a mute and a volume, or the capped refusal, or "this page cannot play the chat"; a talker's voice_controls gains 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> and VoiceStatus.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 vmix and ignores it (app-native/src/collab.rs), never announces hear, and reads cast/hear off the roster. Verified by voicecast.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, AudioData release and its degradation path, and the listener's decode, duck, local mute, cap awareness and re-configure-on-hand-over), 10 web/test/room-protocol.test.ts cases (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), three runtime/src/room.test.ts cases (the wire shapes, and the frozen listener both never asking and ignoring), six ui/tests/rooms.rs cases, 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 (injected AudioData, 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.
    • web/src/challenge-page.ts + the challenges table — the server-rendered challenge page; the feed + challenge handlers live in web/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/likes tables — Stage 4, multi-provider. session.ts is the HMAC token codec (testable in node) and owns the payload version; identity.ts is the injected provider registry (githubIdentity, discordIdentity, PROVIDERS); handle.ts is the pure handle-claim + collision ladder and the written argument for why a handle is immutable; accounts.ts is the seam holding every account rule (resolve-an-identity-to-a-user, link, unlink) so the endpoints stay thin; auth.ts is login/callback/dev/link/unlink/me plus the /auth/signin chooser page; profile-page.ts renders /@user with provider badges. Runtime: runtime/src/auth.ts (session capture from the callback fragment, signIn(provider?)/fetchMe/fetchAccount) and runtime/public/studio-host.js, which owns sign-in and the token-carrying publish for the Rust workbench's browser shell. Covered by web/test/auth.test.ts and the 42-case web/test/providers.test.ts (both stores + the migration), plus the runtime/scripts/auth-providers-e2e.mjs drive against a live Worker.
    • web/src/registry.ts + pack-page.ts + the packs/pack_versions tables — Stage 3. registry.ts is the spec grammar + sha-256 content address; the publish/resolve/browse handlers live in handlers.ts (publish is authed, idempotent on identical content, and refreshes description/license/tags per publish); version sources live in R2 at content-addressed packblob/<hash> keys (entry, modules, samples; the version row's manifest names them); pack-page.ts renders /packs and /p/@user/name. Covered by web/test/registry.test.ts and web/test/pack-files.test.ts (both stores over FakeD1).
    • web/src/room-protocol.ts + room-do.ts + room-page.ts — Stage 5. The RoomRelay Durable Object (room-do.ts, exported from index.ts) is a thin socket adapter over the pure room-protocol.ts reducer (message types + RoomState join/leave/reduce, host-only-event enforcement, and a fail-closed decodeClientMsg — node-tested in room-protocol.test.ts, no Workers runtime). For co-editing the reducer also ferries opaque sync/awareness frames 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 no yjs dependency in the Worker. It uses the WebSocket Hibernation API (acceptWebSocket
      • webSocket* 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 via verifySession. index.ts binds ROOMS + branches WS upgrades (/api/room/:id/ws) to the DO ahead of the HTTP handlers; wrangler.toml declares the SQLite-backed DO + a v1 migration. room-page.ts renders /r/:id (room-page.test.ts); the whole relay is exercised end-to-end over real WebSockets under wrangler dev.
  • Backend (web/): a Cloudflare Worker — see web/README.md for the API, setup, and the wire contract. Pure request logic (web/src/handlers.ts) over a PasteStore seam (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.ts server-renders the song page (HTML-escaped — the XSS boundary); web/src/share-link.ts reconstructs the #s= fragment in lockstep with runtime/src/share.ts so 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 foreign Origin is refused unless listed in MCP_ALLOWED_ORIGINS) — see Use Volta from your own AI agent (MCP).
  • Dependency: lz-string (URI-safe compress/decompress) — in both runtime/ and web/ (the song page reconstructs the same fragment).

Notes & limits#

  • Imports by value. The imports field (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_id threads 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, point VOLTA_REGISTRY (CLI) / VITE_PASTE_API (workbench) at wrangler dev.
  • Deploying the Worker is make deploy-worker (scripts/deploy/deploy-worker.sh): staging is a workers.dev origin needing only wrangler login and two secrets; production is the volta.fm route set in web/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 /community for that reason). A split-origin deploy (site on the droplet, Worker on workers.dev) has to agree in three places — VITE_PASTE_API at site build, the site's CSP connect-src, and the Worker's WORKBENCH_ORIGIN — all driven from VOLTA_WORKER_ORIGIN in .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 project volta on the same zone. Pages refuses files over 25 MiB, so the set in web/src/site-large.json (the UI wasm, everything under /models/) goes to the volta-site-large R2 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 is wrangler 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 (no song block) 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 frozen room.html audience page's own align (room-view.ts → song-player.ts alignBeatOrigin) stays unit-tested but undriven; its client is pinned against the widened protocol by room.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.html audience 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.rs room_window, EngineBridge::join_room over app-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::OpenFiles with the entry file, seed-if-empty past the sync gate), sends play/stop/scene from StartAll/StopAll/a manual ApplyScene (a walk's own at_bar steps 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's room_anchor, from the same audio-thread anchor. A contested host request degrades at the relay; the roster's echo is what hosting follows, 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 or volta-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, --join with a session, a contested host request) and was seated as a listener in an owned room sends the !ask-edit marker on its first hello — 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's anon flag (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 hello named 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 RoomSnapshot wire type carries imports and macros; the browser host fills imports from the program it last actually installed (installedSnapshotOf in runtime/src/studio-room.ts), so a listener that holds no documents can still render a multi-file song. macros stay 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-studio has no WebRTC stack and no WebCodecs: it reads the roster's voice / cast flags and says "voice · N in the call — browser-only for now", and it recognises-and-ignores both signal and vmix frames so the widened protocol cannot upset it. Because it never announces voice nobody offers to it, and because it never announces hear the 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 reports failed and the dialog says a TURN relay may be needed.
  • Audience voice needs WebCodecs, and is capped. A publisher needs AudioEncoder + MediaStreamTrackProcessor and a listener needs AudioDecoder — Chrome-shaped today. Without them the page never casts / never announces hear, 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.