Writing Files Other Programs Trust, from the Browser

Stem Shovel's Studio is a small DAW in a browser tab, and the point of a session is to leave with it. A band records, comps and balances a song, then wants to carry it into Logic, Pro Tools, Reaper, Cubase, Live or Audacity to finish. Exporting a WAV from the browser is easy. Exporting a WAV that Logic places at the exact second it was recorded, an MP3 that plays everywhere, a zip that Cubase opens as a project, and a MIDI file whose tempo map survives the trip is a different problem, because every one of those files is a contract with a program we cannot run in the browser. This post is what each format needed before the program on the other end believed it, and one lesson about reading files that the Web Audio API hides from you.
All of the encoders are pure functions over typed arrays. That is what lets a test read the bytes back and check them, which is the only confidence we have before a real DAW opens the file.
A WAV Is 44 Bytes of Header
The 24-bit WAV encoder (src/lib/utils/encodeWav24.ts) is the smallest piece. A RIFF header, a fmt chunk declaring PCM, the channel count, the sample rate and 24 bits a sample, then a data chunk of interleaved little-endian samples. Each float is clamped to the unit range and scaled:
const x = Math.max(-1, Math.min(1, channels[c][i] ?? 0));
const s = Math.round(x * 8388607);
v.setUint8(at, s & 0xff);
v.setUint8(at + 1, (s >> 8) & 0xff);
v.setUint8(at + 2, (s >> 16) & 0xff);
The one design decision is a parameter for extra chunks, each already padded to an even length, written between fmt and data. That slot is what makes the file something more than a WAV.
The Chunk That Places a Clip
A session in the Studio is clips at positions on tracks. When it leaves as audio, a DAW that imports WAVs at the insert point loses every position, and the person importing has to nudge each clip into place by ear. The fix is thirty years old: the Broadcast Wave bext chunk (EBU Tech 3285), whose timeReference field is the first sample's position, in samples at the file's own rate, since midnight. Logic reads it as "Move to Original Record Position", Pro Tools as "Spot" to the original time stamp, Reaper as "Insert at original time stamp", and Cubase and Ardour read it too. A clip exported with its start in that field lands at its start.
The writer (src/lib/utils/bext.ts) builds a version 2 chunk: 602 fixed bytes, then an optional coding history line. The position is a 64-bit integer written as two 32-bit halves, so a clip past the 32-bit boundary (about 25 hours at 48 kHz, which a mistaken timestamp can reach) is still correct, and the test checks a value past 2 to the 33rd power reads back unchanged. Five loudness fields follow, in hundredths of LU and dBTP, with 0x7fff meaning "not set". We fill the integrated loudness and true peak from the measurement described in Measuring Loudness in the Browser, so a mastering engineer's tool shows the clip's level before it plays.
The text fields are ASCII by the standard, and the writer maps anything outside Latin-1 to a question mark. The test for that writes a description with a typographic dash in it and asserts the dash comes back as ?. It looks like a shortcut; it is the standard, and a file that quietly holds UTF-8 where a DAW expects ASCII opens fine in one program and shows garbage in another.
Every clip in a session bundle gets its own file named for its track, its clip and its start, stamped with timeReference: Math.round(clip.start * sampleRate). Every track also gets a full-length stem from zero, because Audacity, Live, FL Studio and GarageBand ignore the time stamp and import at the insert point; one file per track from the start of the song works anywhere. The bundle is verified by a real program: bun run interop:reaper opens the export in Reaper under a virtual display and reads the clip positions back from the time references through Reaper's own API.
A Zip Is a Zip
Everything leaves the Studio in one archive, built in the browser by fflate's synchronous zip (src/lib/utils/zipBlobs.ts). Each entry carries a compression flag, and audio is stored with no compression at all:
files[e.name] = [bytes, { level: e.compress ? 6 : 0 }];
PCM and AAC are already dense; deflating them costs seconds of CPU on a phone and saves almost nothing. Text (the README, the labels file) is deflated at level 6.
The same container is the project format. A DAWproject, the open interchange format from Bitwig and PreSonus that Cubase 14 and later, Bitwig and Studio One open directly, is a zip holding project.xml, metadata.xml and the audio files. Our writer produces the two XML documents, hands them to the same zipBlobs with the clips' WAVs, and names the result .dawproject with the type application/vnd.bitwig.dawproject. There is no second zip library and no special path.
The XML is where trust had to be earned. The writer had emitted Volume before Pan and Mute inside a Channel element since its first version. Bitwig's own reader accepted it, and so did ours, so nothing complained. Then we validated the output against Bitwig's published Project.xsd with xmllint, and it refused the file: the schema's sequence is Mute, Pan, Sends, Volume. A lenient reader hides exactly the errors a strict one will find later, so every writer test now validates against the schemas kept in the repo, through libxml2-wasm inside Vitest. The DAWs we most want to reach do not read DAWproject at all (Logic, Pro Tools, Live, Audacity, Reaper), which is why the session bundle exists beside it.
The README Is Part of the Format
Inside the bundle is README.txt, plain text, generated from the export (src/lib/utils/sessionReadme.ts). It names what is in each folder, the sample rate of the clips, and the steps for each DAW:
Logic Pro
Start an empty project. File > Import > MIDI File on the .mid first: Logic takes the
tempo map, time signatures, key and markers when the project is empty. Then drag the
clips/ folder in and choose Region > Move > To Original Record Position (or import
stems/ at bar 1 instead).
The file is written for the person who unzips the bundle a month later with no Stem Shovel page open. A format that needs a web page to explain it is a format that will be used wrong, and the order matters (Logic takes the tempo map from a MIDI file only into an empty project), so the order is in the file.
MP3 Without a Server
The Studio also exports MP3, and no browser ships an MP3 encoder: WebCodecs has none today. The encoder is LAME 3.100 compiled to WebAssembly, packaged as @mediabunny/mp3-encoder, driven through mediabunny's Output and Mp3OutputFormat (src/lib/audio/encodeMp3.ts). The extension runs LAME in a worker it spawns from a blob URL, and it is imported on first use, so the WASM stays out of the page's initial bundle until someone actually asks for an MP3:
let registration: Promise<void> | null = null;
const registerEncoder = () =>
(registration ??= import("@mediabunny/mp3-encoder").then((m) => m.registerMp3Encoder()));
The rest is the format's own rules. The bitrate snaps to the MPEG-1 Layer III constant bitrates (192 kbps by default), because a constant-bitrate LAME file is the same file everywhere. More than two channels are mixed down to two. A sample rate MP3 cannot carry, 96 kHz say, is resampled to 48 kHz on the way in. Samples go to the encoder in five-second chunks as planar float, which keeps memory flat on a long bounce.
MIDI Is Mostly Arithmetic
The drum machine, the chord player and the session bundle all write Standard MIDI Files through one writer (src/lib/utils/midiFile.ts) that sorts absolute ticks, converts them to delta times and emits the chunks. The drum machine and the chord player write format 0, one track. The session bundle writes format 1: a conductor track with the tempo map, meters, key signatures and markers, then one track per MIDI part. All of them run at 96 ticks per quarter note.
The events are bytes. A tempo is a meta event holding microseconds per quarter note; a time signature holds the numerator, the denominator as a power of two, and the metronome's click rate; a marker is text meta event type 6, which Logic, Pro Tools, Cubase, Reaper and Ardour show on the ruler. The drum machine's hits are General MIDI notes on channel 10, with velocity following the cell (ghost 50, normal 100, accent 127) scaled by the row's level, so the mix survives the trip into a DAW. The chord player lets each chord go a sixteenth before the next starts, so a DAW hears separate chords rather than one tied smear. None of that is clever. It is the specification, read carefully, with the musical decisions made explicit in code rather than left to whatever the importing program assumes.
Audacity reads only the notes from a MIDI file, so the bundle also carries labels.txt, the markers and the tempo, meter and key changes as an Audacity label track, for File > Import > Labels.
The File Never Says Its Rate
The last lesson is about reading, and it changed every exporter above. AudioContext.decodeAudioData and its offline twin resample whatever you give them to the context's rate and return a buffer at that rate. Nothing in the result says what rate the file was. We found this building the song sample-rate setting, after noticing that the same song exported on a Mac with a 44.1 kHz interface and on a Windows laptop produced WAVs at different rates, and that nothing stored what rate an uploaded stem had.
So the rate is read from the header by hand (src/lib/utils/audioSampleRate.ts), one small parser per container. WAV and RF64 keep it in the fmt chunk. FLAC keeps 20 bits of it in the STREAMINFO block. MP3 keeps it in the first frame header after any ID3v2 tag, and the version bits halve the MPEG-1 table for MPEG-2 and quarter it for MPEG-2.5. MP4 keeps it in the mp4a or alac sample entry as 16.16 fixed point, so the whole part is a 16-bit read, and a 96 kHz AAC file reads as zero and comes back null. Ogg keeps it in the Vorbis identification header, and Opus is always 48 kHz. AIFF keeps it as an 80-bit extended float, which the parser rebuilds from its exponent and mantissa:
const exponent = (v.getUint16(p, false) & 0x7fff) - 16383;
const mantissa = v.getUint32(p + 2, false) * 2 ** 32 + v.getUint32(p + 6, false);
return Math.round(mantissa * 2 ** (exponent - 63));
Each parser has a test that assembles the header bytes by hand and checks the number. Anything outside 8 kHz to 384 kHz is rejected as a misread.
With the rate known, an import at another rate gets a notification (it plays in time; exports come out at the song's rate), the stem library shows the rate beside an odd item, and the converter decodes at the project's common rate and lists what it resampled.
The flip side of the same fact is a gift. If decodeAudioData always resamples to its context's rate, then an OfflineAudioContext at the target rate is a free, exact resampler, the browser's own, and the whole thing is a dozen lines:
export async function resampleBuffer(buffer: AudioBuffer, sampleRate: number) {
if (buffer.sampleRate === sampleRate) return buffer;
const length = Math.max(1, Math.ceil(buffer.duration * sampleRate));
const off = new OfflineAudioContext(buffer.numberOfChannels, length, sampleRate);
const source = off.createBufferSource();
source.buffer = buffer;
source.connect(off.destination);
source.start(0);
return off.startRendering();
}
The Studio's render now builds its offline context at the song's rate rather than the device's, and the buffer sources resample the sources on the way. A song exported on any machine comes out at one rate, the one the song declares.
What Carried Over
The browser is a fine place to write files. Typed arrays and DataView are all a WAV, a bext chunk or a MIDI file needs, WebAssembly covers the one codec the platform lacks, and a zip library is small. What the browser cannot give you is the program on the other end. So every writer here is pure and tested at the byte level, the XML is validated against the publisher's schema rather than our own reader, the bundle is opened by a real DAW on a virtual display before a release that touches it, and the parts a program will never check (the README, the ASCII text fields, the stems from zero for the DAWs that ignore time stamps) are treated as part of the format. A file is trusted when it opens correctly in a program you never ran, and the only way to earn that is to read the specification as the other program would.
Where those files live once they are written, and how the browser keeps them without promising to, is the subject of a companion post on keeping media in the browser with OPFS.