VOLTAreference

Reference / Work in Studio

Songs and files

Work in Studio

Open in the workbench →
On this page · 4 sections

Keep several songs open, identify the one that is live, and organize sources, versions and library material.

What it is#

Open and live are two different things. Any number of songs are open: real editable buffers with diagnostics, hover, completion, jump-to-definition and undo — and silent. Exactly one of them is live: bound to the engine bridge, the transport, the sample bank, the plugin sidecar, the take lineage and the room. Switching which song is live is the ordinary Open flow, so nothing single-valued ever has to become plural.

The current song name and File → Open Song… open a bounded chooser. Recent, Favorites, On this Mac / On this device (native) or This browser, and Built-in songs distinguish ownership and location. Modules live separately under Sounds → Modules. Two songs can still be put side by side in a split source pane or the compare window. Design: Library Workflow.

On desktop and in the browser, Run, Open and make live compile songs and prepare their display data in the background. You can keep editing or use transport controls while preparation runs. A newer request replaces an older waiting request; failed compilation keeps the previous song loaded. Open keeps your outgoing edits in their tab and adopts the new file when it is ready. Run preserves playback, including a Stop made while compiling. The status line distinguishes compilation from saving the installed song's history.

Run acts on the tab on screen. Every route to Run Buffer — the caret's ⌘⏎, the transport RUN chip, the command palette, the menu — compiles the buffer you are looking at, not whichever song happens to be live. On a parked song tab that means making that song live first, with its unsaved edits, so the engine answers with the music you were reading; on an import tab it re-swaps the live song against the edited module. The shell picks the chord up itself only when no editor holds the keyboard, so a focused pane still runs itself.

Surface#

Tabs and chords#

The source-pane strip shows every open song, then the live song's import tabs. ▸ marks the live song, · marks dirty, ⊘ marks room-denied.

The strip is one row however many songs are open: it scrolls sideways (a plain mouse wheel over it scrolls it too), fades at a cut edge, and scrolls the tab being shown into view. Once it overflows, an all ▾ chip at its end lists every open song by full title (then the imports). A leading · segment two or more tabs share — an album title such as Afterhours Switchboard — is dropped from the chips, and a chip is capped at 32 characters; the hover keeps the full title and file. Restoring tabs= recognises the boot song under another spelling of its path (relative vs absolute), so it is not opened twice.

Chord / routeWhat it does
⌘NNew scratch song in a tab — a real file under ~/.volta/scratch/, labelled untitled until Save As moves it, history and pins included
⌘OShow the song chooser; select a song, then Open to make it live
Alt+⌘OOpen Song Beside… — open in a tab without changing what the engine plays
⌘WClose the active tab (a dirty tab asks twice)
Alt+⌘] / Alt+⌘[Next / previous tab (⌘⇧]/⌘⇧[ were already fold-all / unfold-all)
⌘⏎ on a parked song tabMake it live and run it (see Run acts on the tab on screen below)
⌘\Split source — a second source pane beside the first
File ▸ Compare Songs…Open the compare window against another song
Tab context menumake live · close · close others · compare with… · reveal

Session state persists in the shell string: tabs= (path-backed songs in strip order), activetab=, split=, splittab=, alongside the existing recent= and songfav=. A missing file drops with a toast on restore.

Wire songs. A song that arrives during a session — a community paste, a radio track, a room's shared document — opens as its own tab named after where it came from (SongRecord::display), never over the song you have open. Its scratch file carries that name too (<scratch>/radio.volta, <scratch>/room-jam.volta, <scratch>/<paste-slug>.volta), so the chip keeps saying radio after a reload instead of reverting to untitled, and the radio re-opens its own file rather than leaving one untitled behind per boot. The source-pane head reads the same name — for the live song as well as a parked one — so the chip and the header never name one buffer two things.

The song chooser#

The header holds disk import/open and New song. Search and result metadata sit above the list; the selected song and its primary Open action share one details area — a column beside the list on a chooser at least 900 px wide (a quiet No song selected placeholder until a row is chosen), a strip under it otherwise. More… contains Open in tab, favorites, Stop preview, and local file management. The row context menu and keyboard shortcuts also remain available.

The chooser is bounded by default, with an explicit Expand action. The current song remains behind it. Selection lands on the press, not on a clean press-and-release over one row: a list that reflows between the two — async metadata arriving, a scroll nudge, a trackpad reporting a pixel of travel — used to eat the whole first click, leaving the list scrolled and nothing selected. A press loads or plays nothing; arrows move the selection, skipping group headings, and scroll it into view. Open, Enter or double-click switches the live song. More… → Open in tab opens a silent editable buffer. Escape or Close returns to the previous surface. Dirty songs retain their buffers and undo history in their own tabs.

A row says the song's title over its place — the album's name, not the album:<slug> key the collections sidebar filters on — beside an identity tile tinted by album or folder, then aligned Key · Tempo · Length columns (plus Modified or Added) from the metadata scan, as many as the width allows, with — for anything unread. Its hover adds the blurb's first line, the tempo, the date, the file. With nothing selected the detail strip is one line, not a reserved block. A Recent or Favorites with fewer than four songs fills the space below with Start from a built-in song — four cards (FIRST_PICKS: hello, breaks, tallyman, changes) that each open a copy — and Start a blank song / Open a file from disk cards. The selected song's detail pane carries one requirements line (the four facets Preview is gated on, and nothing else — Needs samples, plugins, or Plays as it is — nothing extra to install), then the author's blurb, wrapped and whole; the file path is under More…, with Copy file path beside it.

Choosing a song leaves a docked lesson (the LEARN panel closes and the song gets the ordinary layout) unless the picker was opened from the lesson's own controls — VoltaUi::open_song_chooser_in_lesson.

  • Recent uses full paths, newest first, up to 40 songs, plus anything written in this location in the last week — a lesson kept as a song, or a New Song, is what a first-timer comes back for and was never opened through this dialog at all. Re-opened songs come first, in the order they were re-opened; the rest follow newest-written first. Its search spans accessible locations; other locations scope search, with Search all locations as an escape. Names and declaration names are searchable.
  • Favorites retains full path identity, including equal filenames in different folders. Rename and move update those identities.
  • Local files default to newest modified, with name/tempo/key sorting and folder navigation. The native picker can open files outside the workspace, including mounted cloud folders. Browser Import from disk… creates a distinct owned document before opening it; cancellation, read failure or storage failure leaves the current song alone.
  • Built-in songs open on a starter shelf: a Start here — good first remixes heading over eight hand-picked songs (hello, hellosketch, helloknobs, breaks, kit, changes, tallyman, first-signal — STARTER_SONGS in ui/src/app/song_chooser.rs, every one snapshotted in compiler/tests/golden_ir.rs so the shelf cannot offer a song that no longer compiles), then All built-in songs. The shelf is the unfiltered list only: a search or a chosen collection is already the reader's own choice. Below it the order is deterministic and named in a visible Sort control — title A–Z by default, plus tempo and the two date orders. modified and key are not offered, because a shipped song answers neither. They use authored collections and derived tempo/capability filters. Open a copy opens an editable buffer under the line "Built-in songs are read-only; you get your own copy." (the head: opened your copy of hello); the first Save creates an owned file. Preview is explicit and disabled while requirements are unknown or the source needs plugins, neural models or live input. Preview failure is shown in the chooser.
  • The selected song has one detail area and explicit actions. More… and the row context menu offer rename (F2), duplicate as version, move, new folder, native reveal, and confirmed trash (Delete), plus recovery restore/discard when present. A failed operation reports its reason in the chooser. The list refreshes after successful operations and when reopened.
  • Lists are virtualized without the former 400-row cap. Metadata scanning is progressive; an unknown fact remains unknown until read. In the browser, library source requests run asynchronously, up to four at a time, so browsing and searching remain available during the first scan. Completed static reads are reused for the rest of the page session; owned documents are read fresh when their metadata needs updating. Compact windows retain folders and collections through menus.

Sounds → Modules has its own search, a virtualized module list and one selected module's detail. It lists the shipped stdlib modules (in the browser too, from the set the page pushes), the live song's own module copies (the browser's per-document buffers), the registry packs it imports, the workspace files, and shipped songs — every .volta is a module. Rows are keyed by file, not by import name, and each carries an origin chip: stdlib, mine, song (a shipped song) or pack @u/x.

  • Exports are what another file can import: the compiler's volta_compiler::module_summary (no priv bindings, no main/song), each with its kind (instrument, pattern, function, …), its doc (the -- block directly above, or @doc), and the module's blurb and its own imports above the list. songmeta's export list is delegated to it.
  • Shadowing is visible. When a file answers the live song's import "drums" before the shipped drums (a drums.volta beside the song or at the workspace root, or the browser song's own copy), both rows appear: the winner says wins over stdlib drums, the shipped one hidden by ws/drums.volta. A hidden module's exports are listed but not inserted — the import would reach the file that hides it.
  • Inserting spells the import the song's resolver really reaches: a stdlib name, the song's own buffer or pack by name, and a workspace file by import_spelling_for (kits/toms from a song a folder up). A file the song cannot reach is refused with the reason, never written as a broken import.
  • ▶ auditions an export through the shared audition slot (the Reference ▶'s offline render): an instrument plays a short phrase, a pattern or track plays itself; the running song is untouched. source opens the module at that export.
  • Shipped modules open read-only. Open read-only (or a stdlib import tab) shows the text with a SHIPPED MODULE strip; typing, format and Rack edits change nothing, and Save refuses. Make my copy forks it where it shadows the shipped one for the live song — the song's folder, else the workspace root (natively; refused when the song sits beside the shipped files, as in the checkout's examples/songs/), or the browser document's own import buffer — and the tab becomes the editable copy.
  • New module (the tab's chip, or File → New Module) writes module.volta (module2, …) beside the live song natively and opens it; in the browser it is a new import buffer of the song.
  • File → Extract to Module… moves the top-level bindings the live song's selection (or caret) covers — plus the helpers only they use, made priv — into a new sibling module named after the first one (pluck, pluck2, …, the LSP code action's rule), and imports them back. The edit is the compiler's verified ide::extract_to_module; when the moved set depends on names that stay in the song, or the song has errors, it is refused with the reason and nothing changes.

Imported material says where it lives: the song outline ends with an imported section (kick from drums, p.* from pads) whose rows open the module at the name, and a Rack case reading an imported patch shows a from drums chip in its rail that opens the module there.

This is not a personal cloud synchronization service. Native cloud-backed folders are ordinary files through the OS picker. Browser documents remain in this browser's local storage. Discover is public community discovery, not personal cloud storage.

Release review#

Release review is the chooser's fifth location: every shipped song, in the order it entered the repository, each with a verdict, notes and the instruments picked for Volta's library. It is the pass a release needs — audition, decide, move on — and it works the same in the browser as on the desktop.

It is Volta's own catalogue tool, not a way to browse songs, so it is not offered until somebody is running a release pass. Three things turn it on (Curation::enabled): VOLTA_RELEASE_REVIEW=1 in the shell that launched the workbench (native only — wasm has no environment), a curation document that already carries verdicts, and an explicit VoltaUi::review_enable — which is what the host/bench review_open entry point calls. Off, the EXPLORE list is Built-in songs alone.

  • Chronological by default. The sort combo offers added, oldest first (the default on entering the location) and added, newest first, plus name and tempo. The date is the manifest's added field, seeded once from git by runtime/scripts/seed-song-dates.mjs (the earliest first-commit date across the corpus path and the pre-2026-08-15 runtime/public/songs/ path), so neither host has to run git. Re-running the script only fills entries added since; a hand-corrected date is permanent.
  • One verdict per song: Include, Maybe, Skip, or unmarked. The chips sit under the selected song; keys 1 / 2 / 3 mark and 0 clears while no text field has the keyboard. The row's subtitle carries the mark (● include, ○ maybe, × skip) and the meta column carries the date. The sidebar's VERDICT list filters to one mark (or to the unmarked remainder) and tallies each.
  • Notes are free text per song. Instruments for the library is one checkbox per instrument the song declares — a top-level name = patch …, multisample … or plugin … binding, read by the same scan that finds tempo and key (ui/src/songmeta.rs::SongMeta::patches; a function that builds a patch is not listed).
  • Play makes the song live and runs it, and the chooser stays open so the next verdict is one click away (SongOpenFollowUp::ReopenChooser). Stop stops everything. Preview (the offline render) and Open a copy are unchanged.

The document is examples/songs/curation.json — tracked source beside the songs it judges, keyed by file stem, hand-editable:

{ "version": 1, "songs": {
  "campanile": { "status": "include", "notes": "…", "instruments": ["bellV"] } } }

Every edit lands in crate::curation::Curation and is written back on the shelf's one-second debounce; a song whose mark, notes and instruments are all cleared loses its record rather than keeping an empty one, so a diff of the file shows decisions, never churn. The desktop writes the file directly (it finds the checkout the way it finds the song library). Under npm run dev the vite middleware in runtime/vite.config.ts serves the same file at /api/curation and accepts it back, so browser and desktop review one document; a hosted build has no route and keeps a localStorage copy, which Copy review JSON in the sidebar exports as text. Like the shelf, the boot read is retried briefly and nothing is saved until it settles — an unseeded document must never overwrite a review pass.

Tagging your own files#

-- @tags a, b anywhere in a file is the file-level tag convention. It is a comment, not language syntax: @doc and @category are decl annotations, and a file-level decorator would be a compiler change for metadata the compiler never reads. The Library's own tags are the authored tags array per entry in runtime/public/songs/index.json (tracked source, seeded by runtime/scripts/seed-song-tags.mjs; a new song is added with runtime/scripts/register-song.mjs, and every writer goes through runtime/scripts/songs-manifest.mjs so the file keeps its house format).

Split source and the two paste verbs#

⌘\ puts a second source pane in the shell's context slot — so at Instrument width the song outline stays and only Timeline/Rack cede. Each pane has its own tab strip and active tab; keyboard focus follows the click and routes Save / Format. The two panes may never show the same tab: the strip greys the tab the other pane holds, and a collision swaps. Below the compact breakpoint the split collapses into the existing one-surface-at-a-time tab switch with both states intact.

Copy or cut in a source pane arms a clipboard shadow. Paste a block into another song and the names it reads dangle; the two verbs then appear as quickfix rows (⌘.) on those diagnostics, so a plain ⌘V never changes meaning:

  • paste with dependencies — pulls the transitive closure across with the patch shelf's own lexer and collision-safe renames, in one insertion for the whole dangling set, under the shelf's MAX_DEPS 16 / MAX_BYTES 24_000. Over cap: plain paste plus a toast naming the names.
  • paste as reference — inserts import "<key>" as <alias> at the auto-import insertion point and rewrites the pasted names to <alias>.<name>. Offered only when the resolver can actually reach the source file from the target; an unsaved scratch source greys the row and says why.

Compare#

The take-diff window is now a compare window over two arbitrary sides:

DiffSide = Live | Song(record id) | Take(content hash) | File(path)

Text, semantic and audio panes are unchanged. Identity is per side — a buffer keys on its editor revision (typing in either song invalidates the panes), a take or file on a content hash captured once when the window opens — and the memo key is the pair. Entry points: the song-tab compare with… submenu, File ▸ Compare Songs…, the history strip's right-click (Live ↔ Take), and compare with parent on a song that was duplicated as a version.

The honesty gate. The audio pane renders both sides offline through the live bridge's root and sample bank. That is right for two songs in the same workspace and a lie for a song whose imports the live root cannot resolve, so a render that never leaves the floor (peak under −80 dBFS) is named as no audio rather than nulling to "identical" — the native bake guard's rule.

Lineage. Duplicate-as-version writes a ForkEdge ({child_rel, parent_rel, parent_content_hash, ts}) as .studio-takes/<child rel>/lineage.json. The take graph has no cross-file node, and that directory already moves and dies with the song, so the edge lives beside the child's takes rather than inside them.

How it's implemented#

  • The song store — ui/src/app/songs.rs: SongRecord { id, path, scratch, parked }. self.live_editor stays a field, and it is always the live song's buffer; a non-live song is parked inside its record (ParkedSong: editor, saved rev, import editors + saved revs + active import tab, nav history, analysis fence, autosave rev) and the two swap slots on make-live. A whole-self accessor would have broken every panel closure that borrows the editor apart from the bridge. SCRATCH_STARTER seeds File → New.
  • Split pane — ui/src/app/splitpane.rs: SplitTarget::{None, Song(id), Import(name)}, BufferRef::{Live, Parked(id), Import(name)}, focused_buffer(). The second pane calls the same whole-self pane methods (parked_song_pane keyed by explicit record id, the shared import_pane, and the live song's live_editor_events) — never a clone.
  • Paste verbs — ui/src/app/pasteverbs.rs: ClipShadow { buffer, path, label, revision, text } set from EditorEvents::copied; code_actions_for prepends the two rows to bridge.code_actions, keyed off the two dangling-name diagnostic shapes the compiler promotes to DiagData::UnknownName. Reuses shelf::capture / shelf::insert_block / shelf::apply_renames.
  • Compare — ui/src/app/compare.rs: DiffSide, SideView, CompareState, compare_window, pair_audio_ui (moved out of dialogs.rs), SILENT_DBFS; open_compare(a, b) / open_compare_pick(a) on VoltaUi. ForkEdge lives in ui/src/takes.rs.
  • The Files surface — ui/src/app/song_chooser.rs (modal, route songs), with listing/operations and the Modules browser in ui/src/app/workspace.rs. Metadata is ui/src/songmeta.rs: scan(&str) -> SongMeta {is_song, tempo_bpm, key, bars, imports, decls, tags} — a pure text scan, no compile, skipping strings and comments, with bars answered only when every arrange step is a plain number — behind SongMetaCache keyed on (path, mtime) and filled at SCAN_BUDGET 24 files/frame (96 while filtering). Facets read code-position identifiers through shelf::code_idents, so a blurb comment never votes. The Modules click uses the compiler's own auto-import rule, volta_compiler::ide::import_edit, which extends the existing selector list (import "m" (a, b)) rather than adding a second statement.
  • File operations — engine-host/src/workspace.rs: WorkspaceEntry, EntryKind, Sidecars, list_entries, rename, move_to_folder, duplicate_as_version, trash / trash_into, create_folder, plus source_declares_song, takes_dir, recover_path, bounces_dir, resolve_folder_path, trash_dir. Every op moves the .recover, the .volta.state.json sidecar and both take directories (.studio-takes/<rel>/ is the desktop store; .history/ is legacy app-tauri), with the primary song file last, so a failure leaves the original untouched. duplicate_as_version writes <name> 2.volta (next free number). Bounces are stem-keyed (Bounces/<stem>/, shared by same-named songs in different folders) — reported as a badge, deliberately never moved. Trash is ~/.Trash on macOS, else a workspace-local .trash/ — never an unlink.

Bridge seams#

volta-ui compiles to wasm and must never name engine-host, so ui/src/bridge.rs carries mirror types — WorkspaceEntry, WorkspaceKind, WorkspaceSidecars — beside the seams. Every seam is defaulted, so a shell without a filesystem declines and the panel greys the action.

SeamNative (volta-studio)Browser (JsBridge)
pick_song_fileOS picker, including mounted cloud foldersfile input → owned document → queued openSong
read_song · write_song_atyesyes
save_song_as (a parked tab's Save As)carries history; a scratch song moveswrites
browse_song (the chooser's metadata scan)the file reader, via the defaultnonblocking: None while the fetch is pending
submit_open_song · poll_open_songbackground preparation with receiptsasynchronous fetch and worker preparation with receipts
new_scratch_song · scratch_diryesyes
write_autosave_for · clear_autosave_for · take_autosave_foryesyes
analyze_at · analysis_start_at (per-entry analysis)yesanalysis_start_at
reveal_pathyesno — a browser has no file manager
workspace_entriesyesyes
rename_song · move_song · duplicate_song · trash_song · create_folderyesyes
library_modulesyesyes (stdlib:<name>, browsed out of the pushed set)
session_modulesno — modules are filesthe document's import buffers (import:<name>)
module_resolution · shipped_moduleyesyes
fork_module · create_moduleyesyes (an import buffer of the song)
load_curation · save_curation (the release review)examples/songs/curation.json in the checkout/api/curation under the dev server, else localStorage
song_parent (the ForkEdge read)yesno
import_spelling_for (the paste as reference gate)yesno

Library scans never block a frame. The song chooser reads metadata through browse_song, not read_song. browse_song answers None for pending — the chooser shows the row as unknown and polls again next frame, never caching an empty scan — and the browser's studio-host.js::browseSongText keeps at most four source fetches in flight and settles each read once per page, so reopening the library costs no network. read_song stays synchronous on purpose: it is the explicit tab read the workbench calls mid-frame while restoring a tab set, and must not be used to enumerate the library. runtime/scripts/studio-library-performance-browser-test.mjs asserts the contract end to end: zero synchronous reads, at most four concurrent fetches, the chooser still painting during the scan, and a p95 frame gap under 100 ms.

import_spelling_for(target, source) proposes candidate import keys shortest-first and verifies each by resolving it back through volta_sources::resolve_module_path (app-native/src/audio.rs::import_spelling), so a shadowed stem is refused rather than guessed. When nothing resolves, the verb yields an action with no edits and the actions bar paints a greyed chip carrying the reason.

The browser store#

runtime/public/studio-store.js — bundler-free plain JS, imported relatively by studio-host.js so the same file resolves for the page and for vitest:

KeyHolds
volta-studio-docsthe index: id, name, folder, kind, mtime, size + declared folders
volta-studio-doc:<id>one document: source + autosave + imports
volta-studio-takes:<id>that document's takes, on their own key so an autosave never rewrites them
volta-studio-tabs{open, active, live} per browser tab — the browser's tabs=/activetab=, since wasm has no eframe Storage
volta-studio-shellthe REST of the workbench's shell state (favourites songfav=, recent=, sample stars, folds, timeline/backdrop/learn settings) — everything the desktop keeps under eframe Storage's volta-shell that the theme/workspace/interaction/tab keys do not own, and never overlay= (the open view is the URL's). Pushed back first at boot through the single push_shell_state slot; /songs/favorites.json seeds the Favourites shelf only when this key is empty (a first visit)
volta-studio-trashsoft-deleted documents (never an rm)
volta-studio-shelfunchanged and whole-session: the shelf is cross-song

Each page-owned piece of shell state (the shell key, theme, workspace, interaction, tab set and favourites seed) is composed in studio-host.js::pushShellState, which re-pushes the union: push_shell_state holds one slot applied next frame, so a second push replaces the first. Favourites are path identities, so web favourites (/songs/<name>.volta) and desktop stars (examples/songs/<name>.volta) do not follow across shells.

A doc id is its path (mine/<name>.volta, scratch/untitled-<n>.volta), matching the desktop's "identity is bridge.song_path()", so tab labels, scratch ordinals and the panel's path arithmetic work unchanged — the whole browser half needed no ui/src/app/* edit. Migration: the first load with no index folds the legacy volta-studio-source / -autosave / -imports / -takes keys into one document named local; a legacy autosave becomes that document's recovery snapshot, not its saved source; the legacy keys stay readable for one release.

The seams cross the boundary as queries on the existing command channel — the page's onCommand returns a JSON string that JsBridge::ask reads — served before the engine-ready gate, since the workbench asks for documents while bootstrapping its editor. Both hosting pages answer, through the one boot they share (runtime/public/studio-boot.js): runtime/index.html and its static twin runtime/public/studio.html. A served path (/songs/…, ?song=, the starter) counts as shipped, so ⌘S on a library preset lands a copy in Mine. Ordinary Open/make-live fetch these paths asynchronously and compile in the preparation worker. The retained read_song query and explicit transactional Open helper still use blocking same-origin XHR. Wasm exports added: push_song_path, push_workspace, push_shell_state, shell_state.

One tab set per browser tab (2026-09-04). localStorage is shared by every tab of an origin and each workbench tab mirrors its whole strip out on a timer, so the last writer won: a second tab's boot rewrote the first tab's tab set, and reloading either one reopened the other one's songs. A remix is exactly that case — the share page and the workbench are two tabs. The tab-set key is therefore {v:2, open, active, live, seq, sessions:{<tab id>: {…}}}: writes are read-modify-write into this browser tab's record only, keyed by an id the host mints into sessionStorage (per tab, and it survives a reload, which is exactly the identity wanted). A tab with no record of its own reads the most recently written one, so a brand-new tab restores the last session as it always did; the top-level fields stay written for readers that predate this. A rename is a fact about the workspace, not one tab, so retabAfterRename rewrites every record. TAB_SESSIONS_CAP (8) bounds the map. The host also re-publishes the workspace when another tab changes volta-studio-docs, so the Files surface stops painting a workspace that has moved on.

A song that arrives over the wire becomes a document at landing (2026-09-04). A remix hand-off (/?remixOf=<id>#s=…), a plain /#s= share link and a ?paste= used to open into a buffer with no document behind it — song_path() stayed null until a ⌘S minted one. That tab was therefore backed by nothing: making another song live sent the workbench through read_song, which throws for a key that is neither a document nor a served path, so the remix's own tab answered "… is not in this workspace" in red and could never go live again, and a reload dropped it. StudioHost.landWireDocument now mints a real Mine document at landing, makes it live and publishes the workspace. Mine, not scratch/, because the browser has no scratch surface at all (entries() excludes it, as the desktop's Mine excludes ~/.volta): a song saved there is one the user could never find. An identical document under the same name is reused, and the spent #s= / ?remixOf= are dropped from the address bar, so re-opening a link does not grow a document per open; ?paste= / ?song= are left, because they mean "open what this points at". The remix toast and the publish lineage (body.parent) are unchanged.

Named scratch documents. EngineBridge::new_scratch_song_named(name, starter) is new_scratch_song's sibling for a song arriving over the wire inside a session (a radio handoff, a room document): it creates-or-replaces <scratch>/<slug>.volta instead of numbering untitled-<n>. The name is then in the FILE, so the tab keeps saying radio / room · jam across a reload and the radio re-opens its own file next session rather than leaving one untitled behind per boot. Both shells implement it (app-native/src/main.rs, ui-wasm/src/web.rs → DocStore.newNamedScratch).

OPFS is decided against (Songs and files §8): its only synchronous API (createSyncAccessHandle) is Worker-only, so it cannot back a synchronous seam from a main-thread workbench; as a write-behind mirror it buys nothing observable while localStorage stays authoritative, cannot be exercised in the runtime's node vitest environment, and adds a second copy of the user's songs that can silently diverge. The want behind it — files you can see — is the File System Access API, and that is its own stage (Ideas).

Notes & limits#

Invariants worth keeping in mind when touching this code:

  • self.live_editor is the live song. Always. A parked song's buffers are in its record, never in the app's slots. A command meaning "the buffer on screen" routes through focused_buffer() (Save, Save As, Format, Undo/Redo, Run), never through live_editor. Save, Format and Run are focus-contested chords: a focused editor answers them, and with nothing focused the shell routes them to the focused buffer; browser_qa's every_advertised_chord_is_answered_with_nothing_focused fails a catalogue chord no handler consumes.
  • One EditorUi per file. Two panes showing one buffer would give egui two focus ids and two carets, so the strip forbids it.
  • Make-live always goes through the open flow — compile-check, arrange-walk suspend and stop_all, re-root, install prepared samples/assets, restore the plugin sidecar, adopt takes. Open resets transport; ordinary release/declick and effect tails may ring out. Takes adopt on make-live, never on tab switch. stop_all in that list is load-bearing and is not interchangeable with stopping the running tracks one at a time: only stop_all re-anchors the engine's transport clock, so only stop_all leaves the opened song at bar 1. The browser host stopped track-by-track, and the first ▶ after an open therefore rejoined the previous song's grid — 30 s of playback before the open put the fresh song's arrangement walk at bar 27 (2026-09-04 QA). The live swap (⌘↵ on the live song) is the opposite case and must never stop the transport: keeping the beat across a recompile is the live-coding model.
  • Loads never auto-play (Arrange a song).
  • Editing a non-live song never touches the engine. update_import is sent only for buffers reachable from the live song, and a parked song is out of the room entirely.
  • Dirty module overrides persist per song and are re-sent for every module the new live root still resolves.
  • A parked song's cross-file jump and compile-verified rename say "make it live first" rather than guessing against the live root.
  • The desktop's main thread never waits on iCloud. main stops it downloading evicted (dataless) files (app-native/src/userfs.rs), so a read there fails at once: a Mine row whose source is evicted lists as Unknown, an open-beside or Compare of one says it is "in iCloud and not downloaded" (download it in Finder), and a launch song that does not load within 20 s (read and compiled on volta-boot-load) opens the default song with a notice. The ordinary open reads on the prepare worker, which may download.
  • Read-only means read-only: saving a library preset lands a copy in Mine.

Background preparation: Studio Song Preparation. Native control-thread boundaries and measurements are in Studio Song Preparation 2026 09 08. Every shell installs a song through one protocol, whatever it is: the module and everything that must land with it — its sample resources, Open's transport stop, a history recall's captured banks and macro writes — are staged into a single batch bound to the revision it was prepared against, and the audio side settles it exactly once. Nothing is installed unless that batch is accepted whole; a refusal, a cancellation or an unresponsive audio device leaves the previous song, its resources and its playing set untouched. The protocol has one mode, which decides only what the host shows while audio decides: the desktop, the plugin and an owned browser engine publish the candidate immediately and roll it back on refusal (so an editor still shows the song it was handed even when nothing is calling the audio callback, and several installs can be in flight); the browser's control worker, which holds the only handle for an audio owner it cannot see, keeps the previous song and refuses every control change until the acknowledgement arrives. One deadline (two seconds) applies to every host: past it, the side that asked settles the install itself and says the engine did not respond, and an audio callback that wakes late finds it decided and keeps the song it had.

On the workbench side the install receipt owns everything that must happen once the change is live (PendingSourceInstall in ui/src/app/source_install.rs): the work hangs off the host's acknowledgement, never off the moment the request call returns. That covers metadata before autoplay, Open before its asset awaits, Stop, history banks and macro writes staged with the module, mixer commits, Save and Export waiting for acceptance, and device output started only after the receipt. When a request is forgotten — a stale owner or sequence, a new epoch, an overtaking request, an unknown outcome — discard_source_installs settles any transaction riding on it rather than dropping it, so a previewed mixer value is rolled back and reported instead of staying audible with no source edit behind it. Song opens follow the same shape: closing the live tab, Close others, and the radio, paste and room paths all go through PendingSongOpen with a SongOpenFollowUp that runs once the opened song is live (ui/src/app/songs.rs).

Native Run and user-requested Open wait for the audio engine's installation decision before publishing source metadata, changing the project root or restoring plugin state. A refused transition leaves the previous song playing. A refused Open or make-live shows that song's tab with its problems listed; a refused Run lists them too. Open includes its sample resources and transport stop in that decision. A newer request can cancel an undecided installation; an installation already accepted by audio still reports its actual outcome. Transport gestures wait briefly for the decision and then address the installed song. The headless harness consumes the same audio decision synchronously, without rendering extra samples; a refused Run/Open preserves its source, root and playback. Windowed source changes use the receipt path; the duplicate synchronous loading helpers are retired. Boot and device rebuild acknowledge on an owned engine before starting its output stream (Production tools in Studio). Browser Run transfers prepared metadata and publishes it on audio acceptance. Browser Open waits for audio acceptance before adopting the document, imports, takes, project root and display metadata. Its transport reset belongs to that same engine decision; refusal keeps the previous document and playback. Newer Open/Run actions can cancel an undecided shared-worker install by request ID. An already accepted decision remains accepted: it updates the installed root, while a superseding editor request keeps its own document. A new room owner prevents the old Open from publishing into that room. Accepted Opens publish before asynchronous asset follow-ups; asset failures become warnings. The unused synchronous browser Open path is retired. Preparation and earlier measurements are in Studio Browser Preparation 2026 09 08.

When the browser's audio engine stops answering. Every browser engine call goes through one port to the audio worklet, so an engine that dies silently — a trapped instance, a wedged render thread, a closed context — would otherwise leave the page waiting forever on work it will never get back. The page bounds every call (a transport call has five seconds, a bank upload or scan two minutes, an install a minute, an offline render ten) and pings the worklet on a timer, and it treats either two unanswered calls in a row or several seconds of total silence as a dead engine. It then says so, fails the calls in flight as unanswered rather than as refusals — nothing is known about what the engine did with them — and rebuilds the engine from scratch: a new audio context, worklet and control worker, then the last module the engine accepted re-installed and the tracks that were playing restarted. It never silently runs the editor's current text instead. Proven end to end, against the real page and a real worklet, by runtime/scripts/studio-engine-liveness-browser-test.mjs, which kills the engine both ways.

Tests: ui/tests/multisong.rs (22, wire names included), ui/tests/split_paste.rs (7), ui/tests/files_surface.rs (33), ui/tests/compare.rs (5), runtime/src/studio-store.test.ts (46, per-tab tab sets and the wire-document landing included), runtime/src/studio-host-community.test.ts (the remix hand-off), the workspace tests in engine-host/src/workspace.rs (18), the songmeta tests in ui/src/songmeta.rs (18), and the room-tab rule in ui/tests/rooms.rs.

Known gaps (full list and reasons: Songs and files §10): the ⌘P palette has no files scope; there is no hover-hold audition; bench notebook cells do not arm the clipboard shadow, and the compiler's MAX_CODE_ACTIONS 8 truncates before the two paste rows are prepended, so a saturated quickfix list can show 10; Mine rows have no compare button of their own; song_parent is native-only, so compare with parent is too, and only duplicate-as-version records an edge at all; in the browser there is no reveal_path, no trash surface, and bounce / plugin-state sidecars are reported honestly absent.