Skip to content

Compile, Save, and Load Projects ​

Project work has three boundaries. compile() checks whether the arrangement can become a renderable timeline. toJson() / fromJson() persist the arrangement model. Loading does not restore PCM, so decoded audio must be rebound before a bounce. Host-separated stems enter through an all-or-nothing import that creates ordinary audio tracks and clips.

TaskAPI
Check the arrangementproject.compile()
Persist the modelproject.toJson() and Project.fromJson(...)
Recover load warningsProject.fromJsonWithDiagnostics(...)
Rebind decoded audiounresolvedAudioSourceIds() and setSourceAudio(...)
Add host-separated stemsimportExternalStems(...)
Compile, persist, rebind, and render
Project editscompile()Deterministic JSONfromJson()Rebind PCMBounce
The saved model restores the arrangement; the host supplies decoded PCM again before rendering.

Compiling the arrangement ​

compile() turns the edited project into a renderable timeline and reports structured diagnostics. Errors (severity 0) mean the timeline could not be built; warnings (severity 1) are non-fatal and the timeline is still renderable.

typescript
const result = project.compile();
// result.hasTimeline     -> true when a renderable timeline was produced (no errors)
// result.diagnosticCount -> number of diagnostics
// result.diagnostics     -> [{ code, severity, targetId, message }, …]
// result.messages        -> newline-joined human-readable detail

if (!result.hasTimeline) {
  for (const d of result.diagnostics) {
    if (d.severity === 0) console.error(`compile error (clip/track ${d.targetId}): ${d.message}`);
  }
}

A common non-fatal warning: a project with MIDI clips but no bound instrument compiles fine, but bounces silently. After a bounce you can read the warnings that render produced with lastBounceCompileResult():

typescript
project.bounce({ numChannels: 2 });
const last = project.lastBounceCompileResult();
// last.diagnostics[0].message ->
//   "project contains MIDI clips; bounce is silent unless an instrument is bound"  (severity 1)

In Python, project.compile() returns the same shape (has_timeline, diagnostic_count, diagnostics, messages).

Save and load: deterministic JSON ​

toJson() serializes the whole project (tracks, clips, MIDI content, loop crossfades, tempo map, time signatures, markers, annotations, warp maps, automation, and part-rig selections) to deterministic JSON: the same project always produces byte-identical text. Project.fromJson(...) restores it. Loop crossfade fields are omitted when they are zero, so hard-loop projects keep a compact JSON shape. Loading requires an integer version from 1 to the supported 5.

The serializer selects the lowest schema needed by the model. A project with no typed or physical fields uses schema 1; a typed automation lane raises it to schema 2; a clip or take with a physical source offset in seconds, or a clip with a loop anchor, raises it to schema 3; comp render parts use schema 4; at least one part-rig entry uses schema 5. The physical source-offset, loop-anchor, comp-render-part, and part-rig fields are JSON/native-model data, so the current JavaScript surface has no setters for the first three; preserve them through the JSON/native model when exchanging projects. A project without these fields keeps the lower schema and its existing content.

Part rigs are stored under part_rigs as entries keyed by destination_id and part. The part is 0–15, or 255 (PART_RIG_ALL_PARTS) for the destination default. Each entry has mode bank, none, or chain; a chain carries 1–8 { processor, params } objects, with params stored as a JSON object string. Project.setPartRig(...) stores an entry even when no track currently routes to that destination, and Project.clearPartRig(...) removes it. Project bounces apply entries to SoundFont and physical-model destinations; SMF export does not include them.

typescript
const json = project.toJson();
// … persist `json` to disk, a database, or postMessage …

const restored = Project.fromJson(json);
try {
  // restored.toJson() === json
} finally {
  restored.delete();
}

Use Project.fromJsonWithDiagnostics(json) when you want to recover non-fatal load warnings (for example dangling source references preserved for repair):

typescript
const { project: loaded, diagnostics } = Project.fromJsonWithDiagnostics(json);
try {
  if (diagnostics) console.warn(diagnostics);
} finally {
  loaded.delete();
}

Python mirrors this with project.to_json(), Project.from_json(json), and Project.from_json_with_diagnostics(json).

A document that cannot load fails with the C ABI's InvalidFormat code on every surface, and the message starts with a diagnostic name. WASM and Node throw SonareError; Python raises SonareValueError carrying the same code. The Python CLI exits 5 for a malformed project document, as the native CLI does.

DiagnosticCauseFatal
malformed_jsonThe text is not a JSON objectYes
missing_versionversion is absent or not a numberYes
invalid_versionversion is 0, or not a non-negative integerYes
unsupported_schema_versionversion is newer than the supported 5Yes
invalid_sample_ratesample_rate is not a whole number of hertz in 8000 to 384000Yes
invalid_formatA container or array element has the wrong type, for example tracks is not an array or an element of clips is not an object; the message names its pathYes
invalid_clip_take_structureA clip's takes repeat an id or use id 0, active_take_id names no take, a take sits on a MIDI track, or comp segments overlap, run past the clip end or carry a negative crossfadeYes
unknown_scene_keyA key in the embedded mixer scene that the scene reader does not consumeNo, returned in diagnostics

An unknown_scene_key warning carries its path from the document root, for example scene.strips[0].faderDB; keys starting with $ or x- are exempt. The rest of the project document is not checked for unknown keys. See Mixing Scene JSON for the same check on a standalone scene.

Reading the model back, and rebinding audio after a load ​

Project JSON stores the arrangement, not the PCM. A loaded project therefore knows it has a source, but has no samples behind it. Three read-only descriptor families plus the PCM and source-metadata setters close that loop.

What the JSON holds
audio tracktake 1take 2take 3comp laneMIDI trackautomation lanebar 1bar 2bar 3bar 40481216position in PPQ · 1.0 = one quarter noteclip startPpq 0 · lengthPpq 8active take — fills uncovered regionstake 1take 3take 2segments pick a take; nothing is copiedMIDI clip
  • take 1
  • take 2
  • take 3
The JSON holds this arrangement: tracks, clips, takes and automation. The audio under each audio clip is not in it; the host decodes it and binds it back.
typescript
const loaded = Project.fromJson(json);

for (let i = 0; i < loaded.trackCount(); i++) {
  const track = loaded.trackByIndex(i);      // { id, kind, midiDestinationId, gain, pan, mute, solo, name }
  console.log(track.id, track.name);
}
for (let i = 0; i < loaded.clipCount(); i++) {
  const clip = loaded.clipByIndex(i);        // { id, trackId, sourceId, startPpq, lengthPpq, … }, in quarter notes
  console.log(clip.id, clip.startPpq, clip.lengthPpq);
}
const unresolvedAudioIds = new Set(loaded.unresolvedAudioSourceIds());
for (let i = 0; i < loaded.sourceCount(); i++) {
  const source = loaded.sourceByIndex(i);    // { id, kind, channelCount, sampleRateHint,
                                             //   nameOrUri, contentHash, externalStemRole }
  if (source.kind !== 0 || !unresolvedAudioIds.has(source.id)) continue; // 0 = audio; skip MIDI
  const pcm = await decodeFromYourStorage(source.nameOrUri);
  loaded.setSourceAudio(source.id, pcm, source.channelCount, source.sampleRateHint);
  loaded.setAudioSourceMetadata(source.id, 'sha256:...', 'lead-vocal');
}

const audio = loaded.bounce({ sampleRate: 48000 });
python
loaded = sonare.Project.from_json(json_text)

for i in range(loaded.track_count()):
    track = loaded.track_by_index(i)
    print(track.id, track.name)
for i in range(loaded.clip_count()):
    clip = loaded.clip_by_index(i)
    print(clip.id, clip.start_ppq, clip.length_ppq)

unresolved = set(loaded.unresolved_audio_source_ids())
for i in range(loaded.source_count()):
    source = loaded.source_by_index(i)
    if source.kind != 0 or source.id not in unresolved:  # 0 = audio; skip MIDI
        continue
    pcm = decode_from_your_storage(source.name_or_uri)
    loaded.set_source_audio(source.id, pcm, source.channel_count, int(source.sample_rate_hint))
    loaded.set_audio_source_metadata(source.id, "sha256:...", "lead-vocal")

audio = loaded.bounce(sample_rate=48000)

From the command line, sonare project bounce --resolve-audio opens the file:// URIs the sources carry (see Bounce from the CLI).

trackByIndex / clipByIndex / sourceByIndex are 0-based over the stored order, paired with trackCount() / clipCount() / sourceCount(). They are descriptors, not handles: mutating the returned object changes nothing. Use them to render a project the host loaded from disk, or to build a UI over a project your own code did not construct.

setSourceAudio(sourceId, samples, channels, sampleRate) rebinds decoded PCM to a source before a bounce, the step that turns "loaded arrangement" into "renderable project".

startPpq and lengthPpq are positions and lengths in quarter notes (1.0 is one quarter note), not MIDI ticks; see Ticks and PPQ and Clips and Tracks.

The sampleRate argument is the real rate of the PCM you pass, so give the decoded rate; the stored sampleRateHint is only a hint and is 0 when unknown. If it differs from the project's sample rate, the compile step resamples the source to the project rate. bounce({ sampleRate }) is different: it does not resample, and it must equal the project rate (48000 in the examples above). Stem import below does no resampling either.

unresolvedAudioSources() (Python unresolved_audio_sources()) returns the full descriptors of those sources in the same order, so a loader can read each nameOrUri without a second lookup; sourceByIndex and the descriptors return the URI untruncated.

unresolvedAudioSourceIds() is the public list of source ids that still need decoded PCM after deserialization. The kind !== 0 guard above is defensive when walking descriptors (0 is audio, 1 is MIDI): MIDI sources have no PCM to bind and no source metadata to update. contentHash and externalStemRole are owning metadata on audio-source descriptors (they are empty for MIDI sources). setAudioSourceMetadata(sourceId, contentHash, externalStemRole) replaces both strings as one undoable edit; pass an empty string to clear either value. WASM uses that positional form, Node also accepts { contentHash, externalStemRole } as its second argument, and Python uses set_audio_source_metadata(source_id, content_hash, external_stem_role) (the C ABI is sonare_project_set_audio_source_metadata). Python uses unresolved_audio_source_ids() and source descriptors named content_hash / external_stem_role; the C getter returns heap strings that the matching free function must release.

Importing host-separated stems ​

If your app already ran source separation (or simply has per-instrument WAVs), importExternalStems turns them into one audio track and clip each, in one transaction.

typescript
const { trackIds, clipIds } = project.importExternalStems({
  sampleRate: 48000,
  stems: [
    { name: 'vocals', layout: 'stereo', planarSamples: [vocalL, vocalR], startFrame: 0 },
    { name: 'drums',  layout: 'stereo', planarSamples: [drumL, drumR],   startFrame: 0 },
    { name: 'bass',   layout: 'mono',   planarSamples: [bassMono],       startFrame: 0, role: 'bass' },
  ],
});

The import is all-or-nothing: if any stem is rejected, the project is left untouched rather than half-populated. It performs no resampling, no retiming, and no gain compensation — every stem must already be at sampleRate, and startFrame places it on the project timeline as-is. The optional per-stem role is host metadata that round-trips through the serializer and does not change any DSP.

Continue to rendering ​

After the project has a timeline and every required audio source is bound, continue with Bouncing Projects. This page does not duplicate instrument-binding or render-option details.