Skip to main content
Lightning Jar - Web Studio Lightning Jar Wordmark

A File Converter That Never Uploads Your File

A File Converter That Never Uploads Your File

Stem Shovel has a page at /convert where you drop a DAW session and get it back in another shape: a DAWproject from Bitwig Studio, Cubase, Studio One or Nuendo, a Reaper .rpp, an Ableton Live .als, or a MIDI file in; a mix, a stem per track or bus, a clean DAWproject, a Reaper project, a session bundle of timestamped clips plus MIDI, or a project for the free Sandyne DAW out. The page is marked experimental. The part we are confident about is the last line of its description: nothing is uploaded. The file is read, decoded, mixed and written entirely in your browser; the page's server load fetches nothing but its own copy, and the file itself goes nowhere.

This is the second of two posts about the converter. The first, When the Spec Is the Binary, Make the Binary Your Test Oracle, is about how we tested the Reaper writer against Reaper itself. This one is about three decisions that shaped the product: processing on the client, learning undocumented formats from a corpus of real files, and telling the user what we changed.

Why Nothing Leaves the Browser

The plan that scoped the converter put it in one sentence: in the browser for everything but audio transcoding, because "the file never leaves the machine, which is the honest pitch for a free tool." A DAW session is unreleased music. A musician asked to upload one to a stranger's server would be right to hesitate, and a free tool that quietly keeps copies is the kind of free that people have learned to distrust. Client-only processing turns that worry into a non-question, and it has side benefits we did not plan for: there is no upload ceiling, no storage to pay for, no account required, and the page can be public and indexable like the tuner and the drum machine.

The browser turned out to have every piece. fflate unzips a .dawproject archive and gunzips a Live set; DOMParser reads the XML of both; OfflineAudioContext.decodeAudioData decodes WAV, FLAC, MP3 and the rest at whatever sample rate the page chooses, and an offline context needs no user gesture to do it. The reader in src/lib/utils/unpackProject.ts sniffs the bytes before trusting the extension:

const head = strFromU8(bytes.slice(0, REAPER_HEAD.length));
if (head.startsWith("MThd") || /\.midi?$/i.test(fileName)) {
	return { read: readMidi(bytes, baseName), format: "midi", entryOf: () => undefined };
}
if (head === REAPER_HEAD || (/\.rpp$/i.test(fileName) && bytes[0] !== 0x50)) {
	return { read: readRpp(strFromU8(bytes), baseName), format: "reaper", entryOf: (path) => byName({}, path) };
}
const gzipped = bytes[0] === 0x1f && bytes[1] === 0x8b;

A MIDI file starts MThd, a Reaper project starts <REAPER_PROJECT, a Live set is gzip (1f 8b) around XML, and a zip (PK) might hold any of them with their media. Every reader produces the same shape, the arrangement Dawn (Stem Shovel's multitrack recorder) uses for its own songs, so the export side is the Studio's own Export panel with a file-backed engine behind it (src/lib/audio/convertEngine.ts) that mixes each track's clips through its fader and pan, sums tracks into buses and buses into the master, and hands Float32Arrays to the same WAV and MP3 writers the recorder uses. The results arrive as ordinary browser downloads, one file each or one zip.

Working this way has limits, and the page says so. Inserts, sends and plugins are not rendered, because nothing in an interchange format carries a plugin another DAW can run; the stems and the mix are the clips through their faders and pans alone, and the automation travels in the project file rather than the renders. A MIDI track has no instrument on the page, so it gets no stem; its notes go to the MIDI file. And it all happens inside one tab's memory, which is a real ceiling on a phone. We learned that ceiling the hard way building the player, in A Multitrack Player in 291 Lines of Browser.

Learning a Format From a Corpus

Two of the four input formats have no public specification. Reaper's is plain text and the first post covers how we settled its meaning. Ableton Live's is harder: there is no Linux build of Live, so we could not run it, and Ableton publishes nothing about the set format. What we had was nine public .als files, saved by Live 9.7 through 12.2.1, and the source of DawVert, an open converter whose Ableton reader had already decoded most of the tags.

That corpus taught us the format. A set is gzipped XML with every value as a <Tag Value="…"/> child. Live 12 renamed MasterTrack to MainTrack and Live 10 renamed ColorIndex to Color; the rest of what we read is stable across four major versions. Times are beats, except where they are not: a warped audio clip's LoopStart and LoopEnd are content beats, and an unwarped clip's are seconds into the file, which the sets confirmed when an unwarped clip of 64 beats had a LoopEnd of 31.3 over a 31.3-second file. The time signature is an enum rather than two numbers:

// The time signature is an enum: numerator = value mod 99 + 1, denominator = 2 to the power of value div 99.
const sigOf = (v: number) => ({ numerator: (v % 99) + 1, denominator: 2 ** Math.floor(v / 99) });

So 201 is 4/4, 200 is 3/4 and 302 is 7/8. And automation envelopes carry an event at time -63072000, which is two years before the song in beats; it is the parameter's value before any point, and the reader treats it as beat zero.

We want to be honest about what this is. The reader is checked against the sets' own numbers (a loop from a 120 bpm pack dropped into a 124 bpm set shows warp markers of 8.000023 seconds to 16 beats, so Live plays the file three percent fast, and the reader reports the stretch), and one Live 12.2.1 set is checked in as a test corpus under its MIT license with its owner's paths made generic. It has not been proven in Live itself. The docs say so, the plan says so, and the first person with a Mac and Live who finds a set that reads wrong will get a fix and a fixture.

Standards Have Dialects Too

DAWproject is the one format here with a real specification. Bitwig and PreSonus published it with an XML schema, Project.xsd, and our writer's unit tests validate its output against that schema with libxml2-wasm. The very first run caught a bug our own reader and Bitwig's reference reader had both forgiven: the writer had put Volume before Pan and Mute inside a Channel since the day it was written, and the schema's sequence is Mute, Pan, Sends, Volume. A lenient reader hides what a writer gets wrong.

Then the schema caught something we could not fix. Bitwig's own exports write a notes clip's controller lanes as <Points> elements beside the <Notes> inside the <Clip>, and the schema's clip type allows a single child there. Bitwig's export does not validate against Bitwig's schema. A <Lanes> wrapper would validate, and nothing in the wild writes one. We write what Bitwig writes, because the point of the format is that Bitwig opens it, and that one test skips the schema check while the rest of the writer keeps it.

The dialects go further. Bitwig writes its tempo with float noise, 100.000167 for a song at 100, and the reader rounds tempos to two decimals because that is what a tempo means. No Cubase, Nuendo or Studio One export is published anywhere we could find, so the next best thing was files other converters had written for those DAWs, and those taught us a lowercase <markers> element, colors with an alpha byte (#a2eabfff), and a folder track with no channel and no children that Cubase calls Input/Output. Each went in as a parser case with a fixture.

Normalize, Then Say So

Every one of those adjustments changes the file. A rounded tempo is not the tempo you wrote; a ramp read as steps is not the ramp you drew; a level converted from decibels to linear gain and clamped to our fader's top is not quite your level. A converter that does this silently is lying a little, and the lie compounds across formats.

So each reader keeps two lists. dropped is what had no place here: devices and plugins by name, session-view clips, transposed clips, a track over our limits. fixed is what came across changed. The entries are plain sentences, pushed where the change happens:

if (!tempoEl) fix("No tempo: 120 bpm assumed");
else if (bpm !== rawBpm) fix(`Tempo ${rawBpm} read as ${bpm}`);

Others read "Times in beats, converted to seconds through the tempo map", "Levels in decibels, converted to linear", "Colors with an alpha byte, read as six digits", "Tempo ramps kept, linear over the beats", and for a clip that referenced another clip, "an alias, written as a copy". The page shows them under a "Normalized" heading beside "Not carried across", each with a count, and the same lists ride along into the notification when a signed-in user imports the file into Dawn instead. It costs a string push per case. It means the person who gets a stem back at a tempo of 100 instead of 100.000167 reads why before they wonder.

What We Would Tell Another Team

The converter shipped in five steps over two days, v0.143.0 on October 21 through v0.147.0 on October 22: the DAWproject page, the lint and repack, Reaper in and out, Live sets in, MIDI files in. That pace was possible because the readers, writers and renderers already existed for the recorder, and a public page was mostly wiring them together. Three things carried over that we would do again anywhere.

Process on the client when the input is someone's private work and the browser can do the job. The privacy story writes itself, and the costs you avoid (storage, upload limits, accounts) are larger than the costs you take on (memory, no plugins).

When a format has no spec, build a corpus of real files before writing a line of parser, read the open-source readers that came before you, and say in the docs what you have and have not verified. "Not proven in Live itself" is a sentence we would rather write than have a user discover.

And when you normalize another tool's dialect into yours, keep a list and show it. The reader is already making the decision; telling the user costs nothing and turns a surprise into a note.

Stem Shovel is at stemshovel.com. The DAWproject specification and schema are at github.com/bitwig/dawproject.

headshot of Kevin Peckham
Kevin Peckham
Principal, Lightning Jar

Ask Eljay

Ask about the studio's work, research, packages, or writing. A few starters:

Answers come from this site's own content and link their sources. For anything that matters, email hello@lightningjar.com.