Skip to content

Bouncing Projects to Audio ​

Bouncing turns your whole arrangement into a single audio file you can save, play, or analyze. If you have used a DAW, it is the "export" or "render" button; in libsonare it is the Project.bounce* family. The rendering happens offline (all at once, not tied to real time: faster on a light project, slower on a heavy one, and never by playing the song back live) and deterministically: on the same build and runtime, the same project plus the same options always yields exactly the same samples, down to the bit.

A Project holds tracks and clips on a timeline. Audio clips already carry samples, so they render directly. MIDI clips carry events (note-on, note-off), not sound, so they need an instrument to become audible, the same way sheet music needs a player. So the choice of bounce method is really one question: which instrument plays your MIDI?

Bounce pipeline: clips to WAV
PROJECT ON THE TIMELINEMIDI clipsAudio clipsInstrument bindingChannel stripMixer sceneMaster busWAV output
Instrument binding picks NativeSynth, SoundFont, or the built-in synth per MIDI destination; the channel strip then applies trim, EQ, inserts, fader, and pan before the mixer sums everything to the master bus.

The whole pass is computed offline, so the result is reproducible every time.

Positions are in quarter notes

Project positions and clip lengths are expressed in PPQ — quarter notes as a float. ppq: 1 is one quarter note. At 120 BPM one quarter note lasts 0.5 s, so a clip of four quarter notes (lengthPpq: 4) is 2 s. Set the tempo explicitly with setTempoSegments([{ startPpq: 0, bpm: 120 }]) so durations in your bounce are predictable.

Bounce reflects the full mixer, not raw clips

Tracks do not sum naively. Each track renders through its channel strip — trim, EQ, inserts, fader, pan, sends, and buses — via the scene mixer. The bounce is the routed master, exactly what realtime playback would produce.

On a MIDI clip, setClipGain and clip fades scale the bound instrument's output at that clip's destination, not velocity. Project Editing lists how the envelope resolves when several clips share a destination. To move a whole MIDI track's level, use the track fader in the mixer scene.

What You Will Learn ​

By the end of this page you should be able to:

  • bounce a project of audio clips with plain bounce;
  • make MIDI audible by routing it through the built-in synth, the NativeSynth, or an SF2 player;
  • pass a NativeSynth patch by preset name, va: prefix, or patch object;
  • host your own instrument from JavaScript or Python through the ExternalInstrument protocol;
  • pre-flight a MIDI clip with validateMidiNotes and read the no-instrument compile warning;
  • let the engine auto-derive the render length, and write the result to a WAV file.

Pick the right bounce method ​

Start at the top and only move down when your MIDI needs a richer instrument.

Your projectUseResult
Only audio clips (no MIDI, or MIDI you want silent)bounceRouted audio; MIDI tracks are silent
MIDI you just need to hearbounceWithBuiltinInstrumentSimple oscillator synth
MIDI that needs real instrument characterbounceWithSynthInstrumentFull NativeSynth (subtractive / FM / modal / piano …)
MIDI that should play a SoundFontbounceWithSf2InstrumentGS-compatible SF2 player
MIDI played by your own synthbounceWithInstruments / bounceWithInstrument (Node, WASM); bounce_with_instruments (Python)Host-supplied ExternalInstrument

One Project, every runtime

The same Project model and core bounce behavior are exposed through WASM/JS, Node native, and Python. Names follow each language's convention (bounceWithSynthInstrument ↔ bounce_with_synth_instrument). The CLI exposes the project workflow as commands (project bounce, including --synth to route MIDI through the built-in synth, plus SMF/MIDI 2.0 import-export), but not every per-destination instrument binding option is wired there. The arrangement model, the compiler, and the DSP code are the same in every runtime; the bit-exact guarantee below holds within one build and runtime, since another platform can round floating-point math differently in the last bits.

Plain bounce: audio clips ​

bounce compiles the project into a renderable timeline and renders it offline to interleaved float audio. Audio clips play through their channel strips; MIDI clips render silently because no instrument is bound.

typescript
import { init, Project } from '@libraz/libsonare';

await init();

const project = Project.create();
try {
  project.setSampleRate(48000);
  project.setTempoSegments([{ startPpq: 0, bpm: 120 }]); // predictable durations

  const track = project.addTrack({ kind: 'audio', name: 'tone' });
  project.addClip({
    trackId: track,
    startPpq: 0,
    lengthPpq: 4,          // 4 quarter notes = 2 s at 120 BPM
    audio: monoSamples,    // Float32Array
    audioChannels: 1,
    audioSampleRate: 48000,
  });

  // Interleaved stereo: [L0, R0, L1, R1, ...]
  const audio = project.bounce({ numChannels: 2, sampleRate: 48000 });
} finally {
  project.delete();        // the WASM handle is NOT garbage-collected
}
python
import libsonare as sonare

with sonare.Project() as project:
    project.set_sample_rate(48000)
    project.set_tempo_segments([{"start_ppq": 0.0, "bpm": 120}])  # predictable durations

    track = project.add_track("audio", name="tone")
    project.add_clip(
        track,
        start_ppq=0.0,
        length_ppq=4.0,            # 4 quarter notes = 2 s at 120 BPM
        audio=mono_samples,
        audio_channels=1,
        audio_sample_rate=48000,
    )

    audio = project.bounce(num_channels=2, sample_rate=48000)  # (frames, 2) float32 ndarray

Node uses the same calls as the browser package. The examples below show the browser form and the Python equivalent.

Always release the Project

Project, like every embind object, holds a WASM heap handle the JavaScript garbage collector cannot reclaim. Call project.delete() in a finally block (Node also accepts destroy(); Python uses project.close()). Leaking handles slowly exhausts WASM memory in long sessions.

Interleaved audio

"Interleaved" means the channels are woven into one array, sample by sample: [L0, R0, L1, R1, …]: left sample, right sample, left, right. (The alternative, "planar", keeps each channel in its own array.) The WAV writer walks this single array straight through, so a 2-channel bounce of N frames is 2 × N floats long.

Bounce options ​

Every field of the options object is optional:

OptionMeaningDefault
totalFramesRender length in output framesauto-derived (see below)
blockSizeRender block size; any positive value is used as given128 (<= 0 too)
numChannelsOutput channel count: 1, 2, 6, or 82 (<= 0 too)
sampleRateOutput sample rate in Hz, a whole number. It must equal the project rate: this option does not resamplethe project sample rate (<= 0 too)
instrumentLatencySamplesHost-instrument PDC (plugin delay compensation) fed to the compiler0

numChannels cannot exceed the mixer scene's master layout

The master bus processes the bounce at numChannels: a "5.1" master layout allows 6, a "7.1" layout allows 8, and a plain stereo master stays at 1 or 2 (see Bus). A value outside {1, 2, 6, 8}, or wider than the master layout, is refused. Bouncing a surround scene at a narrower numChannels than its master carries folds the extra planes down through the same ITU-R BS.775 downmix (the standard surround-to-stereo fold-down) the realtime engine applies, rather than dropping them.

What is PDC / latency compensation?

Some instruments and effects need a few samples of "lookahead" and so report their audio a little late. PDC (plugin delay compensation) tells the compiler how many samples late an instrument is, so the engine can shift it back into alignment and keep every track in time. If your instrument has no latency, leave this at 0.

Determinism is independent of live state

An offline bounce settles every smoothed gain and effect parameter to its target before rendering, so the result does not depend on where a fader or filter sweep happened to be when you started the render. Bouncing right after a live tweak produces the same samples as bouncing from a freshly loaded project. A bounce, like every offline render, stem bounce and freeze, carries the program output only: an armed PFL/AFL cue (pre-/after-fader listen, a monitoring solo) or a solo monitor state never reaches it.

Omitting the length ​

When totalFrames is omitted (or <= 0) the render length is auto-derived from the compiled timeline: the musical end of the arrangement, plus the instrument's release tail, plus the mixer scene's latency and effect tail (a reverb or delay insert keeps sounding after its input stops). A project with content renders without you computing a frame count; an empty project yields an empty buffer. An instrument that reports INT_MAX (2^31 - 1) for tail_samples has an unbounded tail, so an auto-length bounce of a non-empty arrangement is refused; pass a positive totalFrames when you need a bounded window. Pass totalFrames also when you need a fixed-length buffer. A bounce whose derived length overflows, or whose compiled arrangement does not fit the engine, fails with InvalidState.

The reported latency is rendered ahead and dropped: frame 0 of the bounce is the start of the timeline, so the result needs no manual latency trimming.

With useGmPrograms: true, NativeSynth derives the release bound from the programs and drum note/kit combinations the arrangement actually plays. Unused GM voices do not extend the bounce; the piano soundboard and guitar sympathetic resonance count only after a note has activated them. Before the first note after preparation or reset, or after more than 128 distinct patch/drum combinations have been played, the synth's tail query reports a conservative whole-bank bound.

The drum-kit preset (a percussion patch with gmKit on) is bounded the same way: once a note has struck, the tail covers only the kit pieces actually played, so a project that plays one kick over a 2 s clip renders about 2.69 s, not the whole-kit maximum. Before the first note, or after the played set overflows, the tail is the maximum over every piece of the kit.

Bounce MIDI through the built-in synth ​

A MIDI-only project bounced with plain bounce is silent. Route it through the built-in oscillator synth to make it audible with one call. Pass a binding to choose the waveform and envelope, or {} for a default sine patch.

typescript
const project = Project.create();
try {
  project.setSampleRate(48000);
  project.setTempoSegments([{ startPpq: 0, bpm: 120 }]);

  const { clipId } = project.addMidiClip(0, 4);   // MIDI track + clip, 4 quarter notes long
  project.setMidiEvents(clipId, [
    Project.midiNoteOn(0, 0, 0, 60, 100),         // C4 at the start (PPQ 0)
    Project.midiNoteOff(3, 0, 0, 60, 0),          // release three quarter notes in (PPQ 3)
  ]);

  // MIDI-only project -> non-silent stereo audio.
  const audio = project.bounceWithBuiltinInstrument(
    { waveform: 'saw', gain: 0.5 },
    { numChannels: 2, sampleRate: 48000 },
  );
} finally {
  project.delete();
}
python
with sonare.Project() as project:
    project.set_sample_rate(48000)
    project.set_tempo_segments([{"start_ppq": 0.0, "bpm": 120}])

    _, clip_id = project.add_midi_clip(0.0, 4.0)  # MIDI track + clip, 4 quarter notes long
    project.set_midi_events(clip_id, [
        sonare.Project.midi_note_on(0.0, 0, 0, 60, 100),   # C4 at the start (PPQ 0)
        sonare.Project.midi_note_off(3.0, 0, 0, 60, 0),    # release three quarter notes in (PPQ 3)
    ])

    audio = project.bounce_with_builtin_instrument(
        sonare.BuiltinSynthConfig(waveform="saw", gain=0.5),
        num_channels=2,
        sample_rate=48000,
    )

A BuiltinSynthBinding accepts waveform ('sine', 'saw', 'square', 'triangle'), gain, ADSR (attackMs, decayMs, sustain, releaseMs), polyphony, and a destinationId to target one MIDI destination. Every numeric field uses "0 / omit keeps the default", so {} is a usable default patch. The attackMs, decayMs and releaseMs times are linear ramps over the set time. Pass an array of bindings to render several MIDI destinations. Omitting the argument or passing undefined creates one default binding; pass an explicitly empty array [] (or runtime null) to bind nothing and render silently.

Bounce MIDI through the NativeSynth ​

For real instrument character, bounceWithSynthInstrument routes MIDI through the full NativeSynth: its subtractive, FM, Karplus-Strong, modal, additive, percussion, and piano engines plus the realism layer. There are three ways to specify the instrument:

typescript
import { init, Project, synthPresetNames } from '@libraz/libsonare';

await init();
synthPresetNames();   // ['sine', 'saw', 'square', 'triangle', 'saw-lead', 'square-lead', ...]

// 1. A preset name string
const a = project.bounceWithSynthInstrument('saw-lead', { numChannels: 2, sampleRate: 48000 });

// 2. The same preset with the "va:" routing prefix
const b = project.bounceWithSynthInstrument('va:saw-lead', { numChannels: 2, sampleRate: 48000 });

// 3. A patch object: a base preset plus shared-control overrides
const c = project.bounceWithSynthInstrument(
  { preset: 'warm-pad', cutoffHz: 1200, ampReleaseMs: 600 },
  { numChannels: 2, sampleRate: 48000 },
);
python
sonare.synth_preset_names()   # ['sine', 'saw', 'square', 'triangle', 'saw-lead', 'square-lead', ...]

# 1. A preset name string, 2. the same preset with the "va:" routing prefix
a = project.bounce_with_synth_instrument("saw-lead", num_channels=2, sample_rate=48000)
b = project.bounce_with_synth_instrument("va:saw-lead", num_channels=2, sample_rate=48000)

# 3. A patch object: a base preset plus shared-control overrides
c = project.bounce_with_synth_instrument(
    sonare.SynthPatch(preset="warm-pad", cutoff_hz=1200, amp_release_ms=600),
    num_channels=2,
    sample_rate=48000,
)

Use synthPresetNames() to discover valid names instead of hardcoding magic strings; unknown names throw. A patch object starts from its preset base (or the default subtractive patch when preset is omitted) and overrides the shared controls — see NativeSynth for the field list. A patch gain must be in [0, 4]; a value outside it, or a non-finite one, is refused by name. Amp and filter decay and release times such as ampReleaseMs are three time constants, about −26 dB at the set time. Pass an array to bind several destinations; an empty array binds nothing.

Following GM programs instead of pinning one patch

Each entry passed to bounceWithSynthInstrument (as a SynthPatch object, not a bare preset string) may also carry destinationId (default 0) and useGmPrograms (default false). With useGmPrograms on, MIDI program changes select the matching General MIDI voice per destination while the bound patch stays as the fallback for anything the map does not cover. The sibling spellings are use_gm_programs on the C ABI and auto_select_gm in Python. See Following GM programs instead of pinning one patch for the full explanation and examples.

The piano roll below is exactly this call at work: one MIDI passage routed through bounceWithSynthInstrument and re-voiced live as you switch the preset; the playhead tracks the bounced audio.

MIDI · PIANO ROLLIDLE
A MIDI passage — same notes, any instrument

A three-voice phrase — melody, chords, and bass — drawn as a piano roll. The notes never change; switch the instrument and the engine bounces the exact same MIDI through a different built-in voice. Drag the tempo and the whole sequence speeds up or slows down. Press play to hear the passage; the playhead tracks the audio.

Instrument
Tempo
100 BPM

Deterministic by construction

For a fixed project, options, and patch, bounceWithSynthInstrument is bit-for-bit reproducible on the same build and runtime. The same is true of every bounce method, which is what makes a bounce safe to snapshot in a test or cache by hash.

Bounce MIDI through a SoundFont ​

bounceWithSf2Instrument plays MIDI through a GS-compatible SoundFont player fed by the project's loaded SoundFont. Load the .sf2 bytes first, then bounce:

typescript
const sf2Bytes = new Uint8Array(await (await fetch('/piano.sf2')).arrayBuffer());
project.loadSoundFont(sf2Bytes);

// 16 MIDI channels per player; channel 10 drums via bank 128; GS NRPN + SysEx honored.
const audio = project.bounceWithSf2Instrument(
  { gain: 0.5 },
  { numChannels: 2, sampleRate: 48000 },
);
python
project.load_soundfont(open("piano.sf2", "rb").read())

audio = project.bounce_with_sf2_instrument(
    sonare.Sf2InstrumentConfig(gain=0.5),
    num_channels=2,
    sample_rate=48000,
)

General MIDI, banks, and drums

General MIDI (GM) is the standard 128-instrument set every SoundFont maps to, so program numbers pick the same kind of instrument across files. By convention channel 10 is reserved for drums, addressed as bank 128. NRPN and SysEx are extra MIDI messages for finer or vendor-specific tweaks; you rarely set them by hand.

Programs the SoundFont does not cover — including bouncing with no SoundFont loaded at all — fall back to the built-in synth's GM bank. Call soundFontManifest() to see, per (channel, bank, program), whether each note resolves to 'sf2' or falls back to 'synth'.

Host your own instrument ​

Every runtime can host any instrument you write through the ExternalInstrument protocol: bounceWithInstruments / bounceWithInstrument on Node and WASM, bounce_with_instruments on Python. Events reach the instrument as UMP (Universal MIDI Packet) words, the MIDI 2.0 packet encoding, and the instrument adds its audio into scratch buffers the bounce hands it.

JavaScript (Node and WASM) ​

Only render is required; destinationId (default 0), prepare, onEvent, latencySamples, and tailSamples are optional. onEvent receives { destinationId, words, renderFrame }. render(outputs, frames) gets one zero-filled Float32Array per channel (always two: the engine hosts an instrument as a stereo source whatever numChannels is); whatever the arrays hold when render returns is added into the bounce.

typescript
class SineInstrument {
  destinationId = 0;
  tailSamples = 4096;                       // release tail that extends an auto-length bounce
  voices = new Map<number, { phase: number; amp: number }>();
  sampleRate = 48000;

  prepare(sampleRate: number) {
    this.sampleRate = sampleRate;
    this.voices.clear();
  }

  onEvent({ words }: { words: number[] }) {
    const status = (words[0] >>> 20) & 0xf;      // 0x9 note-on, 0x8 note-off
    const note = (words[0] >>> 8) & 0xff;
    const velocity = words[0] & 0xff;
    if (status === 0x9 && velocity > 0) this.voices.set(note, { phase: 0, amp: (velocity / 127) * 0.3 });
    else if (status === 0x8 || status === 0x9) this.voices.delete(note);
  }

  render(outputs: Float32Array[], frames: number) {
    for (const [note, v] of this.voices) {
      const step = (2 * Math.PI * 440 * 2 ** ((note - 69) / 12)) / this.sampleRate;
      for (let i = 0; i < frames; i++) {
        const s = Math.sin(v.phase) * v.amp;
        for (const out of outputs) out[i] += s;
        v.phase += step;
      }
    }
  }
}

const audio = project.bounceWithInstrument(new SineInstrument(), { numChannels: 2 });
// Several instruments at once: project.bounceWithInstruments([a, b], options), one per destinationId.

Every callback runs synchronously on the calling thread while the bounce is blocked, so a callback that returns a Promise is refused with a TypeError. A callback that throws stops all further callbacks and the bounce rethrows that first error once it returns (a native failure is reported ahead of it). Using the project from inside a callback (a mutation, a nested bounce, delete) throws InvalidState. One instrument shared by tracks that feed different channel strips is refused, because callback audio carries no source-track attribution; bind one destination per strip.

Python ​

In Python, bounce_with_instruments dispatches the same protocol. Only render is required; prepare, on_event, and the latency_samples / tail_samples attributes are optional (duck-typed). Events reach on_event as UMP words (see above), so a note-on arrives as a tuple of packet words rather than a decoded object.

python
import numpy as np
import libsonare as sonare


class SineInstrument:
    """A minimal external instrument: one sine voice per held note."""

    latency_samples = 0      # PDC (plugin delay compensation) you report to the compiler
    tail_samples = 4096      # release/effect tail for auto-length bounces; INT_MAX = unbounded

    def prepare(self, sample_rate: float, max_block_size: int) -> None:
        self.sample_rate = sample_rate

    def on_event(self, destination_id: int, ump_words: tuple[int, ...], render_frame: int) -> None:
        # Decode dispatched UMP words (note-on / note-off) and update your voices.
        ...

    def render(self, channels: np.ndarray, num_frames: int) -> None:
        # channels is a zero-filled (num_channels, num_frames) float32 array.
        # Sum your audio INTO it; never overwrite unrelated frames.
        channels += 0.0


with sonare.Project.from_json(project_json) as project:
    audio = project.bounce_with_instruments(
        SineInstrument(),
        total_frames=0,            # 0 => auto-derive length (+ tail_samples)
        num_channels=2,
        sample_rate=48000,
    )                              # -> np.ndarray, shape (frames, channels)

Every callback runs synchronously on the thread that calls the bounce, so there is no cross-thread state to guard. An exception raised inside a callback propagates to the caller — your ValueError surfaces as a ValueError, not a silent dropout. tail_samples extends an auto-length bounce so a reverb or release tail is not clipped off.

To play several instruments at once, pass instruments=[(destination_id, instrument), ...] instead of a single instrument — each tuple binds one instrument to one MIDI destination.

Validate MIDI before bouncing ​

The compiler is forgiving: a MIDI clip with no instrument bound is a non-fatal warning, not an error. After compile() the result still has hasTimeline: true, and the diagnostics carry:

project contains MIDI clips; bounce is silent unless an instrument is bound

That message is your cue to switch from bounce to one of the instrument bounces. To catch a real problem, a stuck note, pre-flight each MIDI clip with validateMidiNotes, which checks that every note-on has a matching note-off:

typescript
const report = project.validateMidiNotes(clipId);
// { ok: true, unmatchedNoteOns: 0, unmatchedNoteOffs: 0 }
if (!report.ok) {
  // A hanging note would sound until the render ends. Fix the events first.
}
python
report = project.validate_midi_notes(clip_id)
# NotePairValidation(ok=True, unmatched_note_ons=0, unmatched_note_offs=0)
if not report.ok:
    ...  # a hanging note would sound until the render ends; fix the events first

See Edit MIDI Clips for inspecting and repairing clip events.

Write the bounce to a WAV file ​

A bounce is a plain Float32Array (interleaved by channel). In the browser, wrap it in a 16-bit PCM WAV and offer it as a download:

typescript
function exportWav(interleaved: Float32Array, sampleRate: number, numChannels: number): Blob {
  const bytesPerSample = 2;
  const dataBytes = interleaved.length * bytesPerSample;
  const buffer = new ArrayBuffer(44 + dataBytes);
  const view = new DataView(buffer);
  const writeStr = (offset: number, s: string) => {
    for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
  };

  writeStr(0, 'RIFF');
  view.setUint32(4, 36 + dataBytes, true);
  writeStr(8, 'WAVE');
  writeStr(12, 'fmt ');
  view.setUint32(16, 16, true);                                   // PCM chunk size
  view.setUint16(20, 1, true);                                    // PCM format
  view.setUint16(22, numChannels, true);
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * numChannels * bytesPerSample, true);
  view.setUint16(32, numChannels * bytesPerSample, true);
  view.setUint16(34, 8 * bytesPerSample, true);
  writeStr(36, 'data');
  view.setUint32(40, dataBytes, true);

  let offset = 44;
  for (let i = 0; i < interleaved.length; i++) {
    const s = Math.max(-1, Math.min(1, interleaved[i]));          // clamp before quantizing
    view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
    offset += bytesPerSample;
  }
  return new Blob([buffer], { type: 'audio/wav' });
}

const audio = project.bounceWithSynthInstrument('saw-lead', { numChannels: 2, sampleRate: 48000 });
const url = URL.createObjectURL(exportWav(audio, 48000, 2));
// assign url to an <a download> and click it

What this writer does to the samples

The bounce is 32-bit float, and this writer reduces it to 16-bit integers in the simplest way:

  • Overs are clipped. A float bounce can go above 1.0 (0 dBFS); the Math.max / Math.min clamp cuts those peaks flat, which is audible distortion. Measure the peak first and lower the master if it exceeds 1.0.
  • Samples are truncated, not dithered. setInt16 drops the fraction, which leaves quantization error that follows the signal; on quiet fades and reverb tails it can be heard as grainy distortion. For a final 16-bit file, round instead of truncating and add TPDF dither (the sum of two independent random values of ±0.5 LSB each, where one LSB is a single 16-bit step) before rounding, or write 24-bit or 32-bit float WAV and let the delivery tool reduce it.

In Python, the CLI writes the WAV for you (next section); from the library, hand the np.ndarray to soundfile or your WAV writer of choice.

Bounce from the CLI ​

The sonare CLI (both the Python package and the native build) has a project subcommand that loads a project JSON and renders it to WAV without writing any code:

bash
# Plain bounce (audio clips; MIDI tracks render silently)
sonare project bounce --in song.json -o master.wav --sample-rate 48000

# Route MIDI through the built-in synth, following the project's GM programs
sonare project bounce --in song.json -o master.wav --synth

# Or pin every destination to one preset from the NativeSynth catalog
# (`sonare project synth-presets` lists them all)
sonare project bounce --in song.json -o master.wav --synth saw
sonare project bounce --in song.json -o pad.wav --synth warm-pad

# A project with unresolved audio sources needs their samples supplied first:
# open every source's own file:// URI in one pass...
sonare project bounce --in song.json -o master.wav --resolve-audio

# ...or bind one source explicitly by id (repeatable, once per source)
sonare project bounce --in song.json -o master.wav --audio 1=vocals.wav

# Inspect first: compile diagnostics (including the no-instrument warning)
sonare project compile --in song.json --json
FlagMeaning
--in FILEProject JSON (required)
-o FILEOutput WAV
--sample-rate NMust equal the project rate (not a resample); defaults to the project rate
--frames NRender length in frames; 0 or omitted derives it from the arrangement
--block-size NRender block size
--channels N1, 2, 6, or 8, at most the scene master's width (default 2)
--instrument-latency NInstrument delay compensation in samples
--audio ID=WAVBind one decoded file to one audio source (repeatable)
--resolve-audioOpen the file:// URIs the project's sources carry
--synth [PRESET]Bare: follow the project's GM programs. With a value: pin every destination to that preset

A project's sources entries name their audio by URI only, and the library never opens one itself, so a project whose clips reference audio renders nothing until the command line supplies the samples. --resolve-audio opens the file:// URIs the document's sources already carry; any other scheme is refused by name rather than skipped:

Error: source 1 (https://example.com/take.wav): --resolve-audio opens file:// URIs only; pass --audio 1=FILE

--audio <source_id>=WAV instead binds one decoded file to one source, addressed by the numeric id from the project's sources array (or from the refusal message below); repeat the flag once per source. A source still unresolved when the render starts is refused with its id, its URI, and the option that would have supplied it:

Error: source 1 (file:///song/vocals.wav) has no audio; pass --audio 1=FILE or --resolve-audio

A bound source keeps the channel count of the file it was decoded from, so binding a mono take does not upmix it. midi-render accepts neither flag; a project bounced through it with an unresolved audio source still hits the bare Error: [7] Invalid state rather than a message naming options midi-render does not have.

Bare --synth follows the project's General MIDI program changes per channel, with channel 10 routed through the GM drum-kit map, and falls back to the sine patch for anything the map does not cover. Use it when the project carries real GM programs. --synth <preset> pins every destination to one preset from the NativeSynth preset catalog (--synth warm-pad, --synth saw-lead); sonare project synth-presets lists the names. SF2 and per-destination synth JSON remain binding-only (WASM/Node/Python); use the Project API for SoundFont-backed bounces. Other useful subcommands are project new, project validate, project synth-presets, and project abi.

Recipes ​

MIDI-only project to a downloadable WAV

The whole MIDI-to-file path, instrument and export included.

typescript
const project = Project.create();
try {
  project.setSampleRate(48000);
  project.setTempoSegments([{ startPpq: 0, bpm: 120 }]);
  const { clipId } = project.addMidiClip(0, 4);
  project.setMidiEvents(clipId, [
    Project.midiNoteOn(0, 0, 0, 60, 100),
    Project.midiNoteOff(3, 0, 0, 60, 0),
  ]);
  const audio = project.bounceWithSynthInstrument('saw-lead', { numChannels: 2, sampleRate: 48000 });
  const url = URL.createObjectURL(exportWav(audio, 48000, 2));
} finally {
  project.delete();
}
Catch a silent MIDI bounce before you ship it

Compile, read the warning, and only then choose an instrument.

typescript
const result = project.compile();
const hasMidiWarning = result.diagnostics.some((d) =>
  d.severity === 1 && d.message.includes('bounce is silent'),
);
const audio = hasMidiWarning
  ? project.bounceWithSynthInstrument('saw-lead', { numChannels: 2 })
  : project.bounce({ numChannels: 2 });

With a clean bounce in hand you have a finished mix. To shape what each track sounds like before it reaches the master, tune its channel strip in the Mixing Engine; to capture more material to arrange and bounce, see Recording and Takes.