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.
| Task | API |
|---|---|
| Check the arrangement | project.compile() |
| Persist the model | project.toJson() and Project.fromJson(...) |
| Recover load warnings | Project.fromJsonWithDiagnostics(...) |
| Rebind decoded audio | unresolvedAudioSourceIds() and setSourceAudio(...) |
| Add host-separated stems | importExternalStems(...) |
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.
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():
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.
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):
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.
| Diagnostic | Cause | Fatal |
|---|---|---|
malformed_json | The text is not a JSON object | Yes |
missing_version | version is absent or not a number | Yes |
invalid_version | version is 0, or not a non-negative integer | Yes |
unsupported_schema_version | version is newer than the supported 5 | Yes |
invalid_sample_rate | sample_rate is not a whole number of hertz in 8000 to 384000 | Yes |
invalid_format | A 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 path | Yes |
invalid_clip_take_structure | A 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 crossfade | Yes |
unknown_scene_key | A key in the embedded mixer scene that the scene reader does not consume | No, 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.
- take 1
- take 2
- take 3
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 });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.
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.
Related
- Project Editing — tracks, clips, tempo, markers, warp, and automation
- Project MIDI — annotations, assist sidecars, auto-tempo, and SMF overview
- MIDI 2.0, UMP, and Clip Files — lossless MIDI 2.0 clip-file exchange