Skip to content

Export & loudness

Export is fully offline: the song renders faster than real time into a WAV, and every render also measures its own loudness. The AI uses that loudness number to master to a target without ever leaving the listening loop.

Complete export jobs are serialized to bound browser CPU and memory pressure. Probe and collection songs receive an explicit context, never connect to Destination, and use silent per-song instrument collectors. Barline never swaps Tone’s ambient live context, so preparation cannot send MIDI, touch hardware returns, or rebind live playback while a bounce is running. Queueing, preload, render, and encode all honor the same cancellation signal.

Both renderers take the song builder (define), the render options, and an optional ExportControls:

import { renderToWav, renderStems } from "@barline/runtime";
import build from "./my-song"; // the song's default export: (song: Song) => void
const { blob, lufs } = await renderToWav(build, { bpm: 132 });

RenderOptions makes the delivery window explicit:

interface RenderOptions {
bpm: number;
key?: string;
sourceRevision?: string; // authoritative project revision
sampleRate?: number; // 8–192 kHz
tailSeconds?: number | "auto" | "none"; // default "auto"
bars?: number;
barStart?: number; // inclusive
barEnd?: number; // exclusive
allowHardwareSilence?: boolean; // default false
}

The default window is one complete arrangement pass. "auto" tail follows the longest conservative track/send/return/master path, including declared device tails, and is capped at 120 seconds to protect browser memory. Master and measurement renders fail when an unfrozen MIDI or external track has no offline audio. Print/freeze the hardware first. allowHardwareSilence: true is an explicit diagnostic escape hatch and records every omitted route in provenance.

A long render stays responsive because progress is reported as it goes and the work can be cancelled while queued, preparing assets, rendering, or encoding (ADR-021 — the encode is chunked so the main thread never blocks):

interface ExportControls {
onProgress?(progress: RenderProgress): void;
signal?: AbortSignal; // abort to cancel a render in progress
wav?: {
bitDepth?: 16 | 24 | "float32";
dither?: "none" | "tpdf";
};
}
interface RenderProgress {
phase: "render" | "encode"; // the offline DSP pass, then the WAV write
fraction: number; // 0..1 within the current phase
stemIndex?: number; // stems only: 1-based track index
stemCount?: number; // stems only: total tracks
track?: string; // stems only: track being rendered
}

Cancellation does not detach an async song definition or preload from the serialization lock. Already-started work settles before the next export begins, so a canceled graph cannot overlap its successor.

renderToWav(define, opts, controls?) — one master WAV

Section titled “renderToWav(define, opts, controls?) — one master WAV”

Renders the whole arrangement through the master chain into a single file and measures its loudness.

const { blob, lufs } = await renderToWav(
build,
{ bpm: 132 },
{
onProgress: (p) => console.log(p.phase, `${Math.round(p.fraction * 100)}%`),
},
);
// blob: the master WAV; lufs: LoudnessResult

RenderResult is { blob: Blob; lufs: LoudnessResult; provenance: RenderProvenance }. Float WAV preserves finite over-range samples; integer delivery clips to its representable range. TPDF is available for 16/24-bit quantization and disabled for float.

renderStems(define, opts, controls?) — explicit stem topology

Section titled “renderStems(define, opts, controls?) — explicit stem topology”

Renders targets sequentially in fresh offline graphs. Choose a topology instead of relying on an ambiguous meaning of “stem”:

TopologySignal delivered
post-masterSelected track, sends/returns, and master chain
pre-masterSelected track and sends/returns, bypassing master effects
pre-faderSelected track before its mixer fader; sends disabled
post-faderSelected track after fader/pan; sends and master disabled
returnSelected return after its fader; all feeding sends active
return-pre-faderSelected return before its fader
const controller = new AbortController();
const stems = await renderStems(
build,
{ bpm: 132, sampleRate: 48000 },
{
signal: controller.signal,
topology: "pre-master",
names: ["kick", "bass"],
wav: { bitDepth: 24, dither: "tpdf" },
},
);
for (const { track, topology, blob, lufs, provenance } of stems) {
console.log(track, lufs.integrated.toFixed(1), "LUFS");
}

For long or high-rate sessions, set retainStems: false and consume each completed stem through onStem. This keeps only one encoded stem live at a time while preserving sequential progress and per-stem provenance.

requireOfflineAudio: true rejects MIDI and unprinted external tracks instead of silently producing empty freeze audio.

Every master and stem records:

  • authoritative source revision, runtime graph/musical fingerprint, and render timestamp;
  • sample rate/channels, tempo/key, bar window, musical duration, and tail;
  • topology/target plus WAV bit depth and dither;
  • referenced assets and devices;
  • every path’s intrinsic, compensation, and total latency samples;
  • BS.1770 loudness;
  • actual FAUST/worklet/instrument fallbacks plus structured asset, stretch, and device degradations used by that render.

Use renderProvenanceBlob(result.provenance) to download the JSON sidecar. The Studio export menu saves it beside WAV and lossy masters, source-selection renders, and stem sets. Managed bounce/freeze assets retain it in metadata, and MCP render/measure tools return it to the agent.

Studio can freeze a track or return from the Export menu. It renders the target pre-fader to float WAV, uploads and pins the immutable asset, then writes a freeze property into the target’s normal TypeScript:

freeze: {
asset: "user/<hash>",
bpm: 132,
bars: 32,
sourceRevision: "<project fingerprint>",
sampleRate: 48000,
headTrimSamples: 144,
}

Playback substitutes the cache at the same compensated musical sample while leaving fader, pan, mute/solo, meters, and sends live. Delete the property (or choose Unfreeze) to restore the untouched source graph. External tracks must first be printed through a calibrated return.

Every render measures loudness with a true BS.1770-4 meter, so the numbers match what a mastering engineer’s meter would read.

interface LoudnessResult {
integrated: number; // whole-track loudness — the mastering-loop target
shortTerm: number; // the final ~3 s — catches drops/outro level
truePeak: number; // max inter-sample peak, dBTP — keep under ~ -1
}
  • integrated is the loudness of the entire track. This is the number you master toward — push it to your genre target and stop.
  • shortTerm is the loudness of the last ~3 seconds, so you can tell whether the track lands hot or drops out at the end.
  • truePeak is the BS.1770-4 inter-sample (true) peak in dBTP, measured with 4× oversampling — the peak a DAC or a lossy transcode actually produces, which a plain sample-peak misses. Keep it under about −1 dBTP so the master survives MP3/AAC encoding without clipping.

get_status (and so the listening loop) surfaces these as integrated_lufs, short_term_lufs, and true_peak, plus master — instantaneous spectral features of the live master in the same units as a reference track’s features. That closes the mastering loop: the AI renders or plays, reads the LUFS / dBTP / spectral delta, adjusts the master chain, and goes again. A typical techno target is around -8 to -6 LUFS integrated at −1 dBTP.

By default the master bus runs three effects — DEFAULT_MASTER_CHAIN:

glue; // bus/glue compressor — glues the mix, makeup gain
tilt; // single-knob air<->weight tilt EQ — "air" on top
maximizer; // brickwall ceiling — the last gain stage before the meter

glue and maximizer are what move the integrated LUFS: the compressor levels the mix and the maximizer raises the ceiling. tilt shapes tone without touching loudness much. See Effects for every parameter.

Replace the whole chain with setMasterEffects — it is a full replacement, not a merge, so include everything you still want:

import { fx } from "@barline/core";
import type { Song } from "@barline/runtime";
export const config = { bpm: 132, key: "A minor" };
export default function (song: Song) {
// ... tracks ...
// push for a hot, glued master, then re-measure with renderToWav
song.setMasterEffects([
fx.glue({ threshold: -16, ratio: 2.5, makeup: 1.5 }),
fx.tilt({ tilt: 1.2 }),
fx.maximizer({ threshold: -0.8, lookahead: 3, release: 80 }),
]);
}

Render is deterministic offline: the FAUST reverbs (space/hall/plate) and the convolver resolve to fixed impulse responses rather than fx.reverb’s real-time tail, so the same song always renders to the same WAV — and the same LUFS. Source fingerprints, fallback disclosure, and provenance make that mastering loop auditable as well as reproducible.