Move musical material between Volta and other tools using the supported file formats and export workflows.
What it is#
Five volta CLI subcommands that move a song across the boundary to the rest of the studio. Three views of one song, in increasing structure:
| View | Carries | Loses |
|---|---|---|
MIDI (.mid) | notes, the conductor | everything else |
stems (.wav) | the actual sound | every bit of structure |
| DAWproject | tracks, clips, notes, curves, the conductor, mixer levels | the synthesis |
Native-only (the CLI is native); nothing here changes the language or the wasm compiler build. Every format is hand-rolled — the SMF codec, the DAWproject ZIP/XML, the CRC-32 — so no dependency reaches the compiler.
Surface#
volta export midi <file.volta> -o out.mid [--cycles N]
volta import midi <file.mid> -o out.volta [--grid 16]
volta export dawproject <file.volta> -o out.dawproject [--cycles N] [--samples dir] [--strict]
volta import dawproject <file.dawproject> -o out.volta [--grid N]
volta export stems <file.volta> -o dir/ [--cycles N] [--dry] [--samples dir] [--no-prints]
[--target-lufs <LUFS|off>] [--ceiling-dbtp DBTP] [--max-loss LU] [--bit-depth <16|24|float>]
volta convert --timeline <file.volta> [--groups] [--song NAME] [-o OUT | --write] [--bars N] [--json]
volta convert is not a file format: it rewrites a scene song as a written timeline in the same language, below.
Export MIDI#
Schedules the module's song and writes a format-1 SMF: a conductor track (tempo map, meter map, markers, key signature) then one named MTrk per Volta track. Note ticks are beat · 480 (PPQ 480; a Volta beat = a quarter note), velocities are the exact ones the audio path / |> midiOut would send.
volta export midi song.volta -o song.mid --cycles 8
Where the notes come from depends on how the song is written, and the three cases are the whole of it:
- a written
timeline(Write a song as a timeline) draws from its regions. A timeline song keeps its events on the song's contents, and its receivers carry none, so asking the tracks produced a file with no notes in it at all.engine/src/interchange.rs::export_timeline_noteswalks each prepared region's plan over its own admission window; - an
arrangeschedule gates each track to the bars its scenes occupy; - anything else (no song, or a
songGraph) plays a flat--cycles-bar loop of every track, default 8.
The conductor track carries three things.
- Tempo as Set-Tempo metas. A
tempoMap/accel/ritardbecomes a staircase: SMF tempo is piecewise-constant and Volta's is linear in bpm (Tempo changes and independent clocks), so sloped segments are subdivided at about one event per beat. - The meter, as time-signature metas — the module's own
meterand everymeterMapchange, on the beat axis. SMF encodes the denominator as2^dd, so a meter whose denominator is not a power of two (5/6) has no encoding at all: it is dropped and reported, never rounded to one that would move every bar line in the receiving DAW without saying so. The report is anote:line per dropped meter on stderr, naming the meter and the beat it would have landed on (smf::unencodable_time_signatures, called frombuild_smf); a reader who wants the bar lines has to change the meter or accept that the receiving DAW keeps drawing the previous one there. - Markers, as meta
0x06.markersare authored on the bar axis and mapped to beats through the same clock playback uses, so ameterMapmoves them.
Automation is not exported, and that is a decision. A Volta lane targets a macro — an author-named control with an arbitrary unit and range — or a ParamRef like bassT.delay.wet. Neither has a defined MIDI controller number, and neither has a defined mapping from its own unit into 0–127. Picking one (cutoff → CC74, level → CC7) would be a claim that the receiving instrument interprets it the same way, which is true only by coincidence, and the value scaling would be lossy in a way no receiver could undo. So export midi emits no CC at all — matching |> midiOut, which does not either (Send MIDI to instruments) — and automation crosses through export dawproject, in the destination's own unit.
Import MIDI#
Decompiles an SMF to compilable Volta. The file's own tempo, meter and markers become top-level declarations; each track's onsets quantize to a --grid-per-bar grid (default 16 = 1/16) of that meter's bar, velocities become c4=0.7, rests ~, two onsets inside one grid step nest as [a b], and a multi-bar track wraps in |> slow N. A neutral saw patch and a one-scene song make the output stand alone. The emitted source is compiled in-process before writing — import never emits something that will not build.
volta import midi loop.mid -o loop.volta --grid 16
Lossy by construction: polyphony is reduced to the top voice per grid step, CC and pitch-bend are dropped, and per-note durations are the step gate.
Export DAWproject#
DAWproject is Bitwig's open interchange format — a ZIP holding project.xml plus media. volta export dawproject writes a declared subset of it, listed element by element below. The element and attribute names are taken from the format's own Project.xsd; Bitwig compatibility is not claimed, because nothing here has been opened in Bitwig. What is gated is that the subset round-trips through Volta exactly.
volta export dawproject song.volta -o song.dawproject
volta export dawproject song.volta -o song.dawproject --samples banks/ --strict
The archive#
| Member | What it is |
|---|---|
project.xml | the portable document another tool reads |
metadata.xml | the MetaData.xsd document — Title, Producer, Comment |
volta-capability.json | the per-feature capability report, also printed to stderr |
volta/timeline.json | Volta's own sidecar: identity, provenance, authored curves, content digests |
audio/<bank>.wav | embedded media, only with --samples <dir> |
Every member is stored (ZIP method 0), with a fixed timestamp, so two exports of one module are byte-identical.
The declared subset, element by element#
Project version
Application name version
Transport
Tempo id name unit="bpm" min max value
TimeSignature id name numerator denominator
Structure
Track id name contentType loaded — one per Volta track, then one per bus, then Master
Channel id name role audioChannels destination solo
Devices — only when the track hosts a plugin
ClapPlugin id name deviceName deviceID deviceRole deviceVendor loaded comment
Mute id name value
Pan id name unit="normalized" min max value
Sends
Send id name destination type
Volume id name unit min max value
Volume id name unit min max value
Arrangement id
Lanes id timeUnit="beats"
Lanes id track timeUnit="beats" — one per track that has clips
Clips id timeUnit="beats"
Clip name comment time duration playStart playStop enable contentTimeUnit
[fadeTimeUnit fadeInTime fadeOutTime] — audio placements only
Notes id timeUnit="beats"
Note time duration channel="0" key vel
Audio id name timeUnit="seconds" duration sampleRate channels algorithm
File path external
Points id timeUnit="beats" unit [track] — one per automation region
Target parameter
RealPoint time value interpolation
Markers id timeUnit="beats"
Marker time name
TempoAutomation id timeUnit="beats" unit="bpm"
Target / RealPoint
TimeSignatureAutomation id timeUnit="beats"
Target / TimeSignaturePoint time numerator denominator
Scenes — only when the song declares `scenes`
Scene id name
Lanes id timeUnit="beats"
ClipSlot id track timeUnit="beats" hasStop="true"
Clip … as above
metadata.xml
MetaData
Title / Producer / Comment — in MetaData.xsd's own field order
Nothing else is written. Note@channel is use="required" in Project.xsd and a Volta note has no MIDI channel, so it is written as 0; Note@rel (release velocity) is optional and Volta has no such value, so it is not written at all rather than invented as a flat 0.5 and then listed as something the export carries.
On import, exactly these are read, and every element the subset does not know is walked through rather than refused — a foreign writer may nest its own containers around the ones we understand. The reader is hostile-input safe: every ZIP header length is range-checked against the real end of the archive before it is used to slice (a 144-byte archive can claim a 64 KiB file name), XML nesting deeper than 512 elements is refused by name, and the element descent is iterative, so no document can exhaust the stack.
How Volta's model lands in it#
- A clip is unrolled. Volta's regions loop their content; a clip carries the placement's finite evaluated performance — every note the engine would actually play inside it, with swing, nudge,
repeat,rate,@overlap "replace"suppression andboundary = "clip"already folded in. That is what a DAW can play, and it is what a generator needs: a computed placement exports its performance so a host that cannot run the algorithm still gets the notes. The source structure — content id, native period, repeat mode, rate, anchor, offset, rate, phase, boundary, seed, provenance — travels in the sidecar instead. - Clip times are beats, relative to song zero; note times are beats relative to their clip. Both are written as the shortest decimal that parses back to the same
f64, so the read is bit-exact rather than merely close. - Automation values are in the destination's own unit — hertz, decibels, seconds — never normalized.
Points@unitnames which. - The conductor is exact. Volta's tempo map is piecewise linear in bpm and so is
interpolation="linear", so a ramp crosses without the SMF staircase. - A hosted plugin travels as a reference: bundle path and preset name in the element's
comment. The CLI never instantiated the plugin, so it has no opaque state, and it writes no<State>rather than an empty one that would read as "restored". - Launchable
scenesgo to the clip launcher. A written-timeline song can also declarescenes—timeline-04-beacon's three ARE its live performance — and they have no placement at all, so they produce no clip under<Arrangement>. They are written as<Scenes>instead: one<Scene>per scene, one<ClipSlot track="…">per member, each holding one pass of that track's own cycle. That length is authored (the track'scycle_beats) and is what launching the scene plays and loops, so nothing is invented. What does not cross is the launch semantics — when a launch lands, which receivers it takes, when they are handed back — and the scene's ownwith { … }macro pins; both are report rows. A reimport does not rebuildscenes: the slots are read as nothing, and the import's own report says the instrument and mixer do not cross. - A macro-bound mixer level (
sends = { river = (depth * -8 - 12) }) stores the dB expression's value, not a linear amplitude, so its<Volume>is writtenunit="decibel"at the baked initial. The live binding does not cross.
The capability report#
Printed to stderr and written into the archive as volta-capability.json. Every feature gets one row with a fidelity:
| Fidelity | Means |
|---|---|
exact | the value read back is the value written |
lossy | carried, but not exactly — always names its tolerance |
not-exported | present in the song and deliberately not carried — always names why |
not-applicable | the song does not use it |
UNSUPPORTED | present and the subset cannot carry it; the export is degraded and says so |
{
"tool": "volta export dawproject",
"subset": "volta-dawproject/1",
"format": "dawproject", "formatVersion": "1.0",
"source": "examples/songs/timeline-01-riverine.volta",
"song": "riverine", "profile": "timeline",
"status": "ok",
"features": [
{ "feature": "note velocity", "fidelity": "lossy",
"detail": "written as `vel` in 0..1 from the 7-bit MIDI velocity …",
"tolerance": "1/127 of full scale (0.0079)" }
],
"notPromised": ["…"]
}
An UNSUPPORTED row makes status degraded, prints a !! DEGRADED block, and with --strict refuses before writing — the command exits nonzero and no archive lands on disk, so nothing downstream can pick up the degraded file the flag exists to prevent. The case built for it is a songGraph (the gated fixture millrace): it navigates by when/@dwell edges, so it has no finite written order at all, and the flat pass the exporter writes is not the song. Saying so is the point — silent degradation is what §9 forbids.
Audio placements#
An audio region keeps its clip, its span and its fades. Without --samples <dir> no <Audio> element is written at all: that element requires a real duration, sample rate and channel count, and inventing them would be a fabricated fact rather than a missing one. The report names the banks and how to fix it. With --samples <dir> the WAV is embedded under audio/ and the <Audio>/<File> elements carry its real numbers.
Import DAWproject#
Reads an archive back into compilable Volta, landing in explicit event content and regions — a timeline song with region, automation and automate, never a scene song.
volta import dawproject song.dawproject -o song.volta
volta import dawproject song.dawproject -o song.volta --grid 16 # the optional edit
- Notes become
patternFromDataevent records — exact onsets, durations and pitches, on no grid at all. Per-note velocity rides the=vsuffix with one track-level|> gainof1/0.85that moves the whole scale onto 0–127 (a track of one velocity folds it into that gain), so a reimported note reproduces the exported velocity byte exactly. - Content time is measured in notational bars, which is what a region's local clock actually runs on. Under a
meterMapthat is not the same as dividing by one bar length, and the difference compounds: bar 40 of a song that turns 7/8 at bar 32 is beat 156, not 160. - Quantization is an edit, never a step. A default import moves no onset.
--grid Nis the explicit request, reported aslossywith its half-a-step tolerance. - A lane comes back as one lane.
<Points>blocks are grouped by their<Target parameter>, which the exporter gives every region of one lane, so a two-region lane is reassembled as tworegions on oneautomaterather than two independent lanes. Itsgapand@composepolicy travel with it — and@composeis not decoration: two overlapping base curves on one destination are a compile error unless the lane says how they combine. - A muted clip is dropped, not un-muted.
Clip@enable="false"says the clip occupies its span and makes no sound. A Voltaregionhas no disabled state, so reimporting it as a sounding region would add notes the file says are silent: it is omitted, and the count is anot-exportedrow. playStart/playStopare the clip's source offset and play window, and they are honoured: only[playStart, playStop)of the content is kept, and it moves to the clip's own start. A clip whose span outlasts its window is looping it throughloopStart/loopEnd, which this subset does not read, so the placement is shortened to the window it can prove and the row islossy.- A parameter lane becomes a free
macro.bassT.delay.wetnames a Volta insert the reimported song does not have; the curve lands on a macro of its own, with a comment recording the address, and the report says so. - The output is compiled in-process before it is written, exactly as
import midiis.
DAW edits become explicit variations#
The sidecar records a digest of each clip's exported notes beside its region id. On import, a clip whose notes still match its digest keeps the original content and region ids. One whose notes have changed — because someone edited it in a DAW — becomes a new local binding with a provenance comment:
-- VARIATION of `riverIdea` (region `riverT:0`): the exported notes
-- carried digest 8f1e…, this file carries a41c…. A DAW edited it, so
-- this is a NEW local binding rather than a silent overwrite of the original.
riverIdeaV2 = patternFromData { period = 40c, events = -- excerpted
map (\e -> e with { start = 0c, dur = 1c }) (events !"c3=0.82")
++ map (\e -> e with { start = 2c, dur = 1c }) (events !"g3=0.74")
}
An archive with no sidecar (a foreign DAWproject) is read on the portable elements alone: every clip becomes a local content, and nothing claims an identity it cannot prove.
What round-trips, within what tolerance#
Gated over eighteen songs — the ten written-timeline corpus songs and the eight legacy fixtures Unified Timeline § "Fixture songs" names — by cli/src/dawproject.rs's round_trip module and cli/src/interchange.rs's midi_round_trip module. DAWproject: export → import → export, comparing the two archives; MIDI: the written SMF against the engine's note stream. The DAWproject comparison includes track names and clip parentage, so a round trip that renamed a receiver or re-parented a clip fails even when every note is identical.
| Quantity | DAWproject | MIDI |
|---|---|---|
| region / clip start and length | 1e-9 beats (written bit-exact) | n/a — MIDI has no regions |
| note onset and duration | 1e-9 beats | 0 ticks at PPQ 480 (1/1920 of a bar) |
| note pitch | exact | exact |
| note velocity | 0 MIDI steps (written b/127, read round(vel·127)) | 0 MIDI steps |
| tempo | 1e-9 bpm | 1 µs per quarter note (SMF rounds to a 24-bit integer) |
| meter | exact | exact, or dropped-and-reported when the denominator is not a power of two |
| markers | 1e-9 beats | 0 ticks |
| automation point time and value | 1e-9 in the destination's own unit | not exported |
exp / cubic curve segments | 0.5 % of the segment's value travel | not exported |
a quantized reimport (--grid N) | half a 1/N step | half a 1/N step |
exp and cubic need the extra line. DAWproject's interpolation enumeration is hold | linear and nothing else, so a curved segment is carried as a polyline: sub-points are doubled until the measured deviation from the true curve is inside the tolerance, and the report prints the error it actually measured rather than the one it aimed at. The iteration stops at 1024 breakpoints per segment; a segment that has not reached the bound by then is an UNSUPPORTED row naming the error it ACHIEVED, which makes the whole export degraded. (It used to stop at 32 and print the tolerance anyway — a 20 Hz → 20 kHz sweep over sixteen bars travels 19,980 Hz, so the 0.5 % bound is 99.9 and 32 pieces of that curve miss it by 104.7.) The authored segments survive exactly in the sidecar, so a Volta → DAWproject → Volta trip restores the curve; a DAW that edits the points gets the polyline, and the reimport treats it as a variation.
Two things the gate deliberately does not assert. Two overlapping notes of one key on one track are a single (channel, key) pair in MIDI, and the reader's pairing may differ from the writer's — the test skips releases for a key that overlaps itself and says so. And anything the report enumerates as a loss is excluded by name, which is the second half of the gate: a lossy row with no tolerance, or a dropped clip nobody mentioned, fails the test.
Export stems#
One <track>.wav per track (that track alone, but through the full mixer path — strip, sends, bus + master inserts), plus master.wav. A solo stem still runs the tracks it listens to (follower, onsets, tap sources) as silent context, so a ducked or triggered part keeps its motion. --dry bypasses the master insert chain but keeps a master varispeed; the song's pinned prints (Pin a print: keep a bounce as a song-local sample) play unless --no-prints.
Sample banks load exactly as the workbench loads them (volta_engine::bank_load, the one native loader the Studio and render-manifest share): the checkout's runtime/public/samples manifest (found by walking up from the song — zones, name:N variants, source bpms for warp, rootPitch/tune, loop points), ~/Documents/Volta/samples, the song's own samples/ (manifest, files and sub-folder banks) and takes/. --samples <dir> adds one more root on top, read the same way (a manifest root works too). A bank the song asks for that no root supplies is an error naming it (outside a checkout, with the --samples fix). So is a neural part the build cannot render (no --features neural, or an unresolved model) — neither exports as a silent stem.
The default delivery is a 24-bit master targeted to −14 LUFS under a −1 dBTP ceiling, plus float32 stems that preserve the raw render for remixing. --target-lufs <LUFS> and --ceiling-dbtp <DBTP> override the master policy; --target-lufs off requests the backwards-compatible untargeted master (still held under the selected ceiling). --max-loss <LU> caps what the target's limiter may cost in integrated loudness: the master prints quieter than the target instead, and the report says so and by how much (Mastering and delivery § "A loudness-loss budget"). --bit-depth 16, 24 or float overrides the context-sensitive defaults for every written file; the 16-bit path uses deterministic HP-TPDF dither.
When the module declares a song {} with an arrange schedule, the render walks the form (Arrange a song § Offline export): scenes enter and leave on their bars, edges lead-ins ride the handoffs, and scene macro automation runs. --cycles then defaults to the whole form rather than 8.
volta export stems song.volta -o stems/ # the whole arranged form
volta export stems song.volta -o stems/ --cycles 16 # …or just 16 bars of it
# → stems/kickTrack.wav … stems/master.wav
The stems render policy#
What a stem set guarantees.
- Each stem is that track alone through the full mixer path — its own strip, its sends, the buses they feed, and the master chain.
- Other tracks the render still needs as context (for example because a
followerorducklistens to them) play inside it, but only their effect on the listener reaches the stem: neither its direct output nor its sends into a return the stem plays are heard (pinned byoffline::tests::a_stem_carries_no_other_tracks_sends_into_a_shared_return). - Every stem shares the master's sample rate, channel count, frame count and horizon, so the set drops into any DAW already aligned. Under an arrangement a stem is silent wherever the form says its track is not playing.
- A re-render is byte-identical.
- Stems and master render concurrently (
offline::render_concurrently, Studio's export too), byte-identical to rendering them one by one.
What it does not guarantee is that the stems sum to anything. Two separate mechanisms break the sum, and confusing them wastes time:
- The delivery ceiling.
master.wavcarries a time-varying limiter gain the stems do not. A raw stem set reconstructs the un-ceiling'd mix, which is the mix the mixer produced and what a stem set is actually for. Ceiling-ing the stems too would not fix it — it would add a second, differently-timed nonlinearity per stem. Pinned bystems_sum_to_the_unceilinged_mix. - Nonlinear shared processing. A bus carrying a saturator, a compressor or a limiter sees one track's signal in that track's solo render and the sum of them in the mix, and
f(a) + f(b) ≠ f(a+b)for every nonlinearity there is. Re-measured 2026-09-15 on a two-track fixture whose only difference is the shared bus's insert: with a linear bus (plate, which is LTI) the stems reconstruct the mix to −130.7 dB relative to its own peak; withsaturateon the same bus, −11.1 dB. That is a quarter of full scale, not a rounding artefact.cli/src/interchange.rs'ssolo_stems_do_not_sum_through_a_nonlinear_shared_busasserts both: the linear case below −100 dB, and the nonlinear case inside a −20…−5 dB band around the figure above.
Neither is a bug to be fixed by changing what a stem is. What to do instead: print the bus. Render the shared processor as its own stem and treat the tracks that feed it as dry, so the nonlinear stage is bounced once and the arithmetic downstream is linear again. If you need the exact delivered sound, use master.wav; if you need to re-mix, use the raw stems and accept that their sum is the mixer's output, not the print.
Convert a scene song to a written timeline#
volta convert --timeline song.volta rewrites a scene song (scenes plus an arrange schedule) as a written timeline (Write a song as a timeline), previews the diff, and proves the result: it compiles both spellings, drives each through the real engine (the legacy one the way transport ▶ starts an arranged song, the written one the way ▶ starts a timeline) and compares the accepted note events and the audio over the whole song (capped at 512 bars, and it says so when it caps). A comparison that is not identical names the receivers it differs on (kickT −1 +1). Nothing is written without -o or --write; nothing in the corpus is rewritten automatically (Unified Timeline §10).
Flat (the default) writes one region list per track, at the schedule's own spans with adjacent cells merged, every profile value spelled out (phase = "transport" or the "entry" folded from |> phraseOrigin, boundary = "let-ring", repeat = "once" for |> once, the song's length and playback). Scene with { … } automation becomes automate lanes and leaves the scenes; an unconditional edge's fill becomes regions over its lead bars; scenes stay as the live launch surface.
A track placed in two or more regions places its content ONCE, by name:
``volta norun an excerpt of converted output:shakPatis the converted song's own binding --shakTrack`'s content, shared by its 2 regions; the identity keeps its draws @identity "shakTrack" shakTrackIdea = (shakPat |> humanize 0.2) shakTrack = sound [ region 0bars 35bars (shakTrackIdea) { phase = "transport", boundary = "let-ring" }, region 36bars 48bars (shakTrackIdea) { phase = "transport", boundary = "let-ring" } ] |> swing 0.56 |> haas 0.3
A copy of the content's text per region is not the same music: a
compile-time draw (`humanize`, `shuffle`, `scramble`, `someCyclesBy`, `rwalk`,
and the control occurrences `orbit` and friends mint) is keyed by its owning
binding plus a per-binding occurrence index, so the second copy of
`(shakPat |> humanize 0.2)` inside `shakTrack` was occurrence 1 and drew other
jitter — under `swing`, an onset jittered across an eighth line moves a whole
swing offset (`aviary`, measured, not pinned by a test: 385 shaker onsets off, all after
bar 36). A named value
is lowered once and shared by every region, and `@identity` gives it the
coordinate the content had inside the track ([[reference/generative-feedback]]).
A content that is already a name (`region … (rimPat)`) is left as it was, and
`degradeBy` and mini-notation `?`/`|` never diverged (they hash the event index
or the literal's own text). The merge at a seam was never the problem: one
region through a seam draws exactly as the walk does.
**`--groups`** keeps the scenes as structure. Each scene becomes a group
binding and the schedule becomes a `concat` of `region` placements of them,
one per cell, with `repeat n` for consecutive equal cells:
```volta
tempo = 120bpm
v = patch { source = sine (freq pitch), ampEnv = adsr 1ms 100ms 0 50ms }
a = play v [
region 0bars 8bars (!"c3 ~ c3 ~") { phase = "transport", boundary = "let-ring" }
]
b = play v [
region 0bars 8bars (!"c2 ~ ~ ~") { phase = "transport", boundary = "let-ring" }
]
-- converted from the scene song: each scene is a group, placed by `region`
intro = stack [a]
both = stack [a, b]
outro = stack [b]
arrangement =
concat (region 0bars 4bars intro) (
concat (repeat 2 (region 0bars 8bars both)) (
concat (region 0bars 4bars outro) (
region 0bars 8bars intro)))
main = song {
scenes = { intro = [a], both = [a, b], outro = [b] },
timeline = [arrangement],
length = 32bars,
playback = "loop"
}
That is arrange [[4, intro], [8, both], [8, both], [4, outro], [8, intro]], and it admits the same 76 events with a bit-identical render (measured, not pinned by a test). What the grouped spelling decides:
- One region per track, from the group's zero, as long as the longest placement it is used for. A shorter placement (the first
introabove) is cut by the group window (Write a song as a timeline § Groups). The report names every scene used at more than one length. concatof relative placements, not astackof absolute ones, because a cell's weight then lives in one literal and changing it moves every later section — the ripple anarrangeweight edit has.- A group binding takes the scene's name unless the file binds it or it is a builtin, module tag or module setting (
chorus,pulse,form,tuning): thenchorusScene, and the report says so. A file that bindsstackitself gets the list spelling,intro = [a, b]. The scenes record is unchanged and keeps its meaning. - An anonymous scene is written inline (
region 0bars 4bars [a, b]); an empty one issilence. - Edges keep the flat treatment. A
replacefill follows its cut scene (concat (region 0bars 7bars mid) (region 0bars 1bars fill)); an overlay rides it (stack [region 0bars 8bars mid, region 7bars 1bars fill]), placing only the fill tracks the scene does not already hold. - Lanes are unchanged: scene automation becomes the same
automatelanes, listed beside the form intimeline.
What refuses, by name. Both modes: a songGraph, a conditional (when) edge, a destination a scene ramp and a shred both write, a |> at anchor, a track whose content is not a literal play/sound argument, a song with no schedule or an existing timeline. Flat adds Identity: a track in two or more regions that carries its own @identity cannot lend it to its content as well (one identity names one binding); --groups writes one region per track and converts it. --groups adds two:
| Blocker | Why a group cannot carry it |
|---|---|
Seam | A |> once or |> phraseOrigin "entry" part held across a scene change. The walk never relaunches a part that stays; a group placement ends at the seam and the next one restarts the phrase. Flat runs one region through it. |
MeterMap | The bar length changes. Inside a group bars count from the group's zero, so a scene placed after the change is the wrong length and every later placement drifts. Flat writes absolute bars. |
--json prints { ok, song, layout, report, groups, window, events, audio, written } (or { ok: false, song, layout, blockers, kinds }). scripts/convert-corpus.py runs either mode over every scene song in examples/songs/ and tallies it; the Stage 4 numbers are in Timeline Structure §8.
In Studio. The same conversion runs from the workbench, --groups layout: the convert to timeline… chip under the Timeline map, or the Split-scene popover's Convert to a written timeline (keeps scenes as groups) after a clip-edge drag. A window shows the report, the diff and the proof line, and nothing is written until Apply conversion, which is one Undo step. The proof in Studio is the accepted-event half only, over the same whole-song window (volta_engine::equivalence, the code the CLI runs); the line says "events identical · audio not compared here — volta convert --timeline --groups <song> proves it". A blocker is shown word for word and writes nothing. From the popover, the drag is then replayed on the converted song as a region edit — see Write a song as a timeline § In Studio.
How it's implemented#
- Note stream —
engine/src/interchange.rs. Onewalk_planis the whole contract: a legacy track and a written region both hand it a plan and a window and get the scheduler's own onsets, gates and velocities, reusingapply_timing(swing/nudge/push),global_beat_to_track/track_beat_to_global(polytempo → global grid) andevent_dynamics(velocity), so an export cannot drift from what would sound.export_note_eventswalksmodule.tracks;export_timeline_regionswalks the prepared regions and returns each placement's performance beside its id;export_timeline_notesflattens that;timeline_windowsgives the per-receiver spans;beat_at_barexposes the conductor's bar mapping. Four scheduler behaviours it mirrors exactly (Control patterns for individual events): it walkstiming_reachcycles past the window, it applies the same time-origin rule (only source cycle 0 clamps to zero), a|> oncetrack contributes exactly one cycle, and an event carrying a late-bound random guard (mini?,degradeByand the rest of Patterns: events over time § Fresh-per-cycle randomness) is kept or dropped per cycle by the same evaluation the scheduler makes (sounds_in), so each exported cycle has the draws that cycle plays. - SMF codec —
cli/src/smf.rs, hand-rolled (VLQ, MThd/MTrk chunks, meta + note events, running-status-aware reader). No SMF crate → the wasm build is untouched. - DAWproject codec —
cli/src/dawproject.rs, hand-rolled for the same reason: a stored-only ZIP writer/reader, a CRC-32, an indenting XML writer and a small XML tree reader. A compressed member is refused by name rather than skipped, because silently droppingproject.xmlwould turn a readable file into an empty import. Both readers treat their input as hostile: every ZIP length field is range-checked before it is used to slice, XML nesting is capped at 512 elements, and both the element descent and the tree's own drop are iterative — a deep document is an error, never a stack overflow. - CLI glue —
cli/src/interchange.rs:build_smf(windows + conductor metas),track_windows(timeline → arrange → flat, in that order),arrangement_windows(a Rust port ofresolveArrangementTimeline+trackWindows, minus transition lead-ins),meter_events,render_one,decompile/build_pattern. Dispatched fromcli/src/main.rs. - Stems render —
volta_engine::offline::render_export(orrender_export_arrangementwhen the form is walked), the same deterministic seambounceOfand the audio export use.
What this does NOT promise#
This list travels with every capability report, because "lossless" only means anything inside an explicit supported subset.
- No lossless generic
.alsround trip. This writes DAWproject, not Ableton Live's format, and nothing converts between them. - No lossless generic MIDI round trip. SMF carries notes and a conductor. It does not carry Volta's synthesis, its mixer, its automation, or any structure above a track.
- No Bitwig compatibility claim. The subset follows
Project.xsdelement for element; nothing here has been opened in Bitwig, and until it has, the only thing gated is the Volta → DAWproject → Volta trip. - No voice graph, insert or bus DSP crosses either boundary. A Volta
patchhas no DAWproject equivalent and none is invented. Print audio (volta export stems) to carry the sound. - No plugin state. A hosted plugin crosses as a bundle path and a preset name. No host restores the sound from that.
- No reconstruction of an algorithm from reimported notes. A generator exports its evaluated performance. Reimporting arbitrary edited MIDI or clips cannot reconstruct the expression that produced them, and the importer does not guess — it says the content was computed and leaves the notes.
playOntracks export their raw grid. An audio-triggered track's rhythm comes from a live onset detector (schedule_on_trigger) an offline walk cannot reproduce. The export emits the pattern on its own grid and applies noswing/nudge/pushthere, matching the engine — decorating a grid with an offset the engine never applies would be pure divergence. The grid is a stand-in, not a promise of what will sound.edgestransition lead-ins are not folded into the exported windows of either format.export stemsdoes walk them, viaengine/src/arrangement.rs::resolve_arrangement_timeline.- Scene macro automation is a live-profile feature with no clip or lane to live on in DAWproject, and is reported as such. A launchable scene's NOTES do cross, as
<Scenes>/<ClipSlot>(above); itswith { … }pins and its launch semantics do not, and a reimport rebuilds noscenesat all. - Group VCAs are a control-side multiply the workbench applies; the CLI renders each track at its own strip level.
- Per-note expression beyond velocity — pan, slide, glide overrides, named event params — has no element in the DAWproject subset and is not carried (the sidecar keeps only each clip's note count and digest).
See also#
- Write a song as a timeline — the
regionsurface the DAWproject clips carry. - Draw a change, and record one — the
automatelanes thePointsblocks carry. - Send MIDI to instruments — live MIDI output to hardware (
midi/midiOut), the note stream this shares. - Tempo changes and independent clocks — the beats↔seconds machinery behind the tempo metas.
- Arrange a song — the resolved
arrangeschedule the windows port. - Resample your own sounds — the offline render seam the stems reuse.
- Mastering and delivery — the delivery targets
export stemsapplies.