Arrangement export format
When you export a song from Arrange mode, Signals & Sorcery writes a folder of audio files plus one JSON file, <name> - export.json, that describes them: the song's tempo, its bars and sections down to the audio frame, which layer plays when (and, if you ask for them, the notes each layer plays), and every file. That JSON is a published, versioned format. A visual tool, a game engine or an analysis script can read an export and cut, animate or analyse to the bar, without opening the project file and without knowing how the app works inside.
- Current version: 1.2.0
- JSON Schemas: 1.2.0, 1.1.0, 1.0.0, and v1: the latest 1.x, which every 1.x file validates against
- Changelog: what changed, version by version
- Source: github.com/shiehn/sas-arrangement-export-contract, with the specification, the schemas and the fixtures
- Reader: the reference reader (TypeScript) and its
sas-exportcommand, which checks an export against its version's schema and recomputes itstimingHashandscoreHash(see Validating) - Your tool's checks: if your tool reads these exports, add what it relies on as a consumer fixture, and every change to the format is checked against it
- Licence: MIT, for everything in the contract: the specification, the schemas and the fixtures
Each schema is served at the URL in its $id. A version's URL is the one an export of that version carries in $schema, so a validator can fetch it directly; v1/schema.json always follows the latest 1.x.
Get an export
- In the app: Export… in the arranger header. Tick Include played notes (MIDI) to add the notes each layer plays to the score. See Exporting your song.
- From an agent or a script:
arrangement_export, orsas arrangement export. See Undo and export. The score is written by default (score: falseleaves it out);scoreNotes(--score-notes) adds the notes;pathFree(--path-free) leaves every absolute path out of the file (see Privacy).
The specification, format 1.x
An arrangement export is a folder of audio files plus one JSON file, <name> - export.json, that describes them: the song's tempo and sections in audio frames, the files, and how they were made. This document says what every part of that JSON means, so a consumer (a visual tool, a game engine, an analysis script) can read an export without opening the project file that made it.
- The JSON Schemas: 1.2.0, 1.1.0, 1.0.0. One per format version, plus
v1: the latest 1.x. - What changed and when: the changelog.
- Fixtures that prove each rule below through Signals & Sorcery's own arrangement code:
fixtures/.
Everything here is stated from the code that writes and plays the export, not from listening.
Versions
schemais always"sas-arrangement-export/1". It names the format family.exportVersionis the format's own semantic version. A file without it is1.0.0; files written to this document carry1.2.0.- A patch clarifies wording only.
- A minor adds optional fields; a reader for an older minor keeps working.
- A major removes a field or changes a meaning. It is announced in the changelog first.
$schema(1.1) is the URL of the exact schema version the file was written against, e.g.https://signalsandsorcery.com/contract/arrangement-export/1.2.0/schema.json. signalsandsorcery.com serves every version's schema at its URL;https://signalsandsorcery.com/contract/arrangement-export/v1/schema.jsonis the latest 1.x. The same files ship with the arrangement contract package, inexport/schema/.contractVersionis the version of Signals & Sorcery's internal arrangement semantics the export was made under. It is informational: it moves for internal changes that never touch this format. UseexportVersionto decide whether you can read a file.
A reader should accept every 1.x file whose minor is at least the one it knows, ignore properties it doesn't know, and refuse a major it doesn't know.
The bundle
One export makes one folder, <name> <YYYY-MM-DD HHMM> (local time), with (2), (3) … added when a folder of that name already exists. Inside it:
| File | When |
|---|---|
<name> - export.json | always (this document) |
<name> - Master (<Preset> <LUFS> LUFS, <bit depth>[, <rate> kHz]).wav | when a master was asked for |
<name> - Mix (32f).wav | when the unmastered mix was asked for |
Stems/NN <label>.wav | stems: one per mix bus ("Drums"), then one per layer outside any bus ("Kick (verse)") |
Editable stems/NN <label>.wav | editable stems |
files[] lists every audio file the export kept, with its bundle-relative path (file), its SHA-256, its length in frames (in its own sample rate), its rate, bit depth and peak. Find a file by kind and sha256, not by its name: a user may rename it.
Plugin states and the project's other data are never in the export. A layer's notes are in it only when the user asks for them (1.2, Score).
Timing
Every frame number in the JSON is at the top-level sampleRate, except files[].frames, which is in that file's own rate.
The tempo is project.bpm, in quarter notes per minute, constant over the song. A beat is a quarter note. For a position beat quarter notes after the song start:
frame(beat) = floor(beat × 60 × sampleRate / bpm + 0.5)
A section's startFrame is frame of its first downbeat, and its lengthFrames is frame(its end) − frame(its start). Sections are therefore contiguous. Rounding is done on the song's absolute grid, so it never accumulates. arrangement.endFrame (1.1) is frame of the last bar's end.
A bar holds numerator × 4 / denominator quarter notes of its section's meter: 4 in 4/4, 3 in 3/4, 3 in 6/8.
Proof: fixtures/semantics/timing-frames.json (89 bpm, 4/4 and 3/4 sections). The frames there are the ones the audio is rendered on.
Sample rates
- Sections,
arrangement.endFrame,lengthSecondsandnullTest.framesare atsampleRate, the rate the project renders at. The mix, stems and editable stems are at that rate too. - A master can be delivered at another rate (
options.sampleRate, e.g. 44.1 kHz from a 48 kHz project). Thenmaster.resamplednames both rates, and the master's own frame count isround(frames × toRate / fromRate). Use the export'ssampleRateframes to place sections, and scale bytoRate / fromRateif you need the master's own frame numbers.
Tail
The audio runs past the last bar so reverbs and delays ring out:
tail.mode: "auto": the files end where the mix falls belowfloorDbfs(−90 dBFS), never before the song end and at mostcapSeconds(10 s) after it.tail.mode: "fixed": the files endsecondsafter the song end.
The song end can run past the last bar on its own: a layer's sound decaying after its last note, or an effect that rings out. lengthSeconds is the length of the rendered files, tail included. The last bar ends at arrangement.endFrame (1.1), or at the last section's startFrame + lengthFrames.
Alignment
Every audio file starts at song frame 0, the first bar's downbeat, sample-aligned with the section frames. There is no pre-roll. 1.1 states it per file: files[].startFrame is 0.
files[].latencyMs (1.1) is the file's known uncompensated latency, in milliseconds. It is 0 today. The export renders offline, and the render compensates every latency a plugin reports: it drops that many leading samples, so a file's frame 0 is the song's frame 0. The stems are checked against the mix (nullTest: stems minus mix stays below −60 dB), which would show a misaligned stem.
What latencyMs cannot cover: a plugin that reports less latency than it has, and the sound itself. An instrument's attack, or a sample whose transient sits a few milliseconds in, is part of the music: its onset lands after the note's position by design.
Sections
arrangement.instances[] lists the song's sections in playing order. Each section is one instance of a scene in the arrangement.
- Order: the arrangement orders its instances by
orderKey(compared code unit by code unit), ties broken by instance id.instances[]is already in that order. Proof:fixtures/semantics/instance-order.json. instanceIdis stable across saves, re-exports and a project import, as long as the section exists.labelis the user's label, or<scene name> <ordinal>("Chorus 2", the second chorus). The editor shows an unlabelled section as "Chorus (2)"; the export keeps the form without parentheses.
Layers
A layer is one track of a scene: a loop the scene plays. Its id (layerId) is the catalog track id, the same everywhere in the export. In each section, a layer either plays or doesn't, bar by bar. A section's scene is the layer's home when the layer belongs to that scene; otherwise the layer is a guest there.
What plays in a section is decided by the layer's lane in that section, if it has one:
playis"on","off", or a mask: one character per bar of the section,"1"plays the bar and"0"doesn't. A mask of another length is invalid. Proof:fixtures/semantics/mask-one-char-per-bar.json.- A home layer with no lane, or a lane without
play, follows the section'snewLayers:"on"plays every bar,"off"none. Proof:fixtures/semantics/untouched-home-layers-follow-newlayers.json. - A guest layer plays only through a lane in that section with
playset to"on"or a mask. Proof:fixtures/semantics/guest-layers-play-only-through-an-entry.json.
Guest entries
A guest's lane without play does not play. Guests default to "off". A guest lane may carry a level, fades or a loop position and still be silent until play says otherwise. Proof: fixtures/semantics/guest-entry-without-play.json.
Runs
A run is a layer's continuous playing bars. A run carries on across a section boundary when the layer plays the last bar of one section and the first bar of the next: no restart, no fade, no gap. Fades and a guest's loop position follow runs, not sections.
Loop position
Each layer loops its own scene's loop (loopBars bars in its home meter). At every downbeat a run reads the loop at a position, in quarter notes into the loop:
- A home layer is anchored to the section: bar
nof the section plays loop barn(position(n − 1) × beats per bar), wrapping where the loop ends. A home run that carries into the next section re-anchors there. - A guest reads from its loop's start (position 0) where its run starts, and carries on from there, across section boundaries, wrapping as it goes.
- A lane's
phaseOffsetBeatsshifts that anchor by its value (quarter notes). - A lane's
phaseSegmentsoverride it inside the section. From a segment'sfromBaron (until the next segment), the position at each downbeat isoffsetBeats + (bar − fromBar) × beats per bar, wrapped to the loop. A segment replaces both the anchor rule andphaseOffsetBeatsthere; it doesn't add to them. A segment that continues the current position changes nothing.
Proof: fixtures/semantics/phase-segment-replaces-anchor.json.
Splits
A lane's splits are bar numbers where the editor starts a new clip: selectable, deletable pieces of a run. They have no effect on what plays: no restart, no fade, no gap. Proof: fixtures/semantics/splits-have-no-audio-effect.json.
Fades
- Fades belong to runs. A run's fade-in is the
fadeInBeatsof the lane in the section where the run starts. Its fade-out is thefadeOutBeatsof the lane in the section where it ends. There is no fade where a run crosses a section boundary. - Lengths are in quarter notes, from the run's start and to its end, never longer than the run.
- Curves (
fadeInCurve,fadeOutCurve), as gain at t ∈ [0, 1] of the fade:"equalPower"(the default, and what an unknown name plays): sin(tπ/2) in, cos(tπ/2) out"linear": t in, 1 − t out"exponential": t³ in, (1 − t)³ out"sCurve": (1 − cos tπ)/2 in, (1 + cos tπ)/2 out
Proof: fixtures/semantics/fades-follow-runs-across-sections.json.
Gain
- A lane's
gainDbsets the layer's level in that section. - A scene's gain (the arrangement's
sceneGains, e.g. from "Match scene levels", which matches the scenes' overall loudness by default, or their kicks) adds to every lane of every layer whose home is that scene, wherever it plays. - A lane's
gainEnvelopeis applied in the audio. It is dB points at quarter notes from the section's downbeat, added togainDb. It is linear in dB between points and holds its first and last values beyond them. It applies wherever the lane plays in the section. - Where it acts: on the layer's rendered audio, together with its fades. Then come the layer's track fader and pan, and then its mix bus's effects.
Proof: fixtures/semantics/gain-envelope-is-applied.json.
Alternatives (takes)
A scene's layers can form an alternatives group: a vocal reading's takes (one per verse of its lyrics), a kit's fills. In the catalog each member carries altGroupId and altOrder. Members with the same altOrder are one unit and play together (a take's lead and its harmonies).
- In each section of the scene one unit plays: the section's pick (
instances[].altPicks[groupId]in the arrangement) when it names a unit that exists, else the unit at the section's position. The k-th section of the scene (its handle number: A2 is the second) plays unit (k − 1) mod n, units in ascendingaltOrder. - The other units rest there: they are silent, and effects placed on them don't sound.
- A member playing as a guest in another scene's section is unaffected.
- Linked sections (sharing one arrangement of the scene) can play different units.
- A scene without groups plays exactly as described above.
Proof: fixtures/semantics/alternatives-play-one-take-per-section.json.
Mute and solo
The arrangement's own mute and solo silence whole layers: a layer sounds when it isn't muted and, if any layer is soloed, it is soloed too. Mute wins. A silenced layer is left out of the mix and the master, and gets no stem. Proof: fixtures/semantics/mute-solo-silences.json.
Effects placed on a lane
A user can place effects on a layer's bar (fills, stutters, reverses, filter sweeps, hits and risers). They are rendered into the audio where they are placed, and nothing is placed automatically.
Common misreadings
Each of these is a plausible reading of the arrangement data, and each is wrong:
| It is tempting to assume | What actually happens | Proof |
|---|---|---|
A guest lane without play plays. | It is off. Guests play only where play says so. | guest-entry-without-play |
gainEnvelope is recorded but not applied. | It is applied in the audio, on top of the lane's gain and its scene's gain. | gain-envelope-is-applied |
A phase segment restarts the loop at 0, or adds to phaseOffsetBeats. | It sets the position to its offsetBeats at fromBar, replacing the anchor and phaseOffsetBeats. | phase-segment-replaces-anchor |
| Fades apply per section. | They apply per run, and runs carry on across section boundaries. | fades-follow-runs-across-sections |
splits restart or fade the loop. | They change nothing that plays. | splits-have-no-audio-effect |
| Every take (or every fill) of a scene plays in every section. | One unit of an alternatives group plays per section, by position or picked; the others rest. | alternatives-play-one-take-per-section |
Score
score (1.2) is the arrangement resolved for a reader: everything this document describes, worked out by Signals & Sorcery's own code, in bars and frames. A consumer needs no rules of its own to follow the music. It is there when the export asked for it (options.score); its notes only with options.scoreNotes.
{
"v": 1,
"noteFields": ["frame", "pitch", "velocity", "durationFrames"],
"tempoMap": [{ "bar": 1, "beat": 0, "frame": 0, "bpm": 120 }],
"meterMap": [{ "bar": 1, "beat": 0, "frame": 0, "numerator": 4, "denominator": 4 }],
"bars": [{ "index": 1, "startFrame": 0, "lengthFrames": 96000 }],
"sections": [{ "instanceId": "…", "sourceSceneId": "…", "firstBar": 1, "lengthBars": 4 }],
"layers": [
{
"layerId": "…", "name": "Kick", "role": "kicks", "bucket": "drums", "drumClass": "kick",
"spans": [{ "section": "…", "startBar": 1, "endBar": 5, "startFrame": 0, "endFrame": 384000, "gainDb": 0, "gainPoints": [] }],
"treatments": [],
"notes": [[0, 36, 100, 6000], [24000, 36, 100, 6000]]
}
]
}
In the score, bar is a song bar counted from 1, beat is quarter notes from the song start, and every frame follows Timing.
Timing maps and bars
tempoMap[]lists where each tempo starts. The tempo is constant today: one entry at bar 1. Read it as a list anyway, so a song that changes tempo later reads the same way.meterMap[]has an entry at bar 1 and wherever a section's meter differs from the previous section's. Each scene has its own meter, so the meter can change today.bars[]is every bar of the song, contiguous. The last ends atarrangement.endFrame.
Sections in the score
sections[] lists the sections in playing order, one per arrangement.instances[]: firstBar, lengthBars, and sourceSceneId, the scene the section plays (sections with the same scene play the same loops). role, the section's part in the song form (intro, verse, build, drop, peak, break, outro), is reserved: the arrangement doesn't carry one yet, so it is absent.
Layers in the score
layers[], sorted by layerId, holds every layer that plays somewhere, or would but is silenced.
nameis the user's track name.roleis the instrument role, ornull(e.g. an audio loop). The known roles arekicks,snares,hats,perc,cymbals,drums(a whole kit),bass,808s,keys,pads,chords,strings,brass,winds,plucked,atmospheres,lead,arp,bells,vocals,fxandunclassified.bucketis the layer's part of the band, by role:bucketroles drumsdrums, kicks, snares, hats, perc, cymbals bassbass, 808s harmonykeys, pads, chords, strings, brass, winds, plucked, atmospheres toplead, arp, bells, vocals, fx nullany other role, or none drumClassmarks a drum-kit piece:kick,snare,hat,percorcymbal, from its role. A whole kit on one layer (roledrums) is"kit", and with notes itsdrumMapgives each played pitch's piece by General MIDI: 35–36 kick, 37–40 snare, 42/44/46 hat, 49/51/52/53/55/57/59 cymbal, any other 35–81 perc, anything elseunknown. 808s are bass and have nodrumClass.silent: true: the arrangement's mute or solo silences the layer. It then has no spans, treatments or notes.
Spans
spans[] says where the layer sounds, one span per run of the arrangement payload (Change detection), in song bars and frames. Masks, guest entries, phase segments, splits, takes and mute are already resolved: a span is sound.
startBar/startFramestart it, andendBar/endFrameare the first bar and frame after it.continues: true: the span carries on the previous section's span, the same run: no restart, no fade.gainDbis the lane's gain plus its scene's gain.gainPointsis its envelope in frames, dB on top ofgainDb(Gain).fadeIn/fadeOutare{frames, curve}, from the span's start and to its end (Fades).- The track fader, pan and the mix bus's effects act after all of this and are not in the score.
Effects in the score
treatments[] lists each placed effect that sounds: its section, type, bar (in the section), bars, params, gainDb, and startFrame / endFrame: its bars, widened to where its audio sounds (a riser starts before its bar, a hit can ring past it).
mode: "replace": the layer's own audio is replaced in those bars (fills, stutters, reverses, gaps, filter sweeps, tape stops), and they carry no notes. A bar loop ("loop") replaces too, with an earlier window of the same layer, so its notes repeat there.mode: "add": it plays on top of whatever plays (crash washes, impacts, hits, risers).
Notes
With options.scoreNotes, each layer that has MIDI carries notes: tuples [frame, pitch, velocity, durationFrames] (their order is score.noteFields), sorted by frame, then pitch. A layer without MIDI (an audio loop, a recording) has none.
- They are the layer's loop MIDI wherever it plays, read through each span exactly as the audio is (Loop position). A home layer re-anchors at each section, a guest carries its position across sections, phase offsets and segments apply, and the loop wraps.
- Only notes struck while the layer plays count: a note held from before a span starts is not struck again. A note's length runs on into the spans it continues into, and stops where its run ends.
frameis where the note sounds in the rendered audio. A loop's audio is a whole number of frames, so over many repeats a note can drift a fraction of a frame per repeat from the ideal beat grid: under a millisecond over a song. Use the frames.- Bars an effect replaces carry no notes; a bar loop repeats its window's notes.
pitchis 0–127 (MIDI; 60 is middle C) andvelocity1–127, as written. The span's level and fades are not applied to them.
Proof: fixtures/semantics/score-notes-follow-the-loop.json and fixtures/semantics/score-effects-and-notes.json.
Ids and stability
| Id | Stable across |
|---|---|
arrangement.instances[].instanceId | saves, re-exports, Save As, and a project import on another machine |
layerId (files[].layerIds, the payloads) | saves, re-exports and Save As. A project imported on another machine gets new layer ids. |
project.id, arrangement.id | saves and re-exports on the same machine. New on another machine. |
arrangement.planId | nothing: new for every render plan |
exportId (1.1) | nothing: new for every export |
files[].layerIds (1.1) lists the layers a stem carries. A mix-bus stem carries several layers, a layer outside any bus one. The mix and the master carry every audible layer and have no list.
Revision
arrangement.revision (1.1) says which edit of the arrangement the export was made from:
seqis the number of the arrangement's latest applied edit in the app's history. It grows with every edit (an undo is an edit too), but not by exactly one. It is local to the machine.docHashis the SHA-256 of the arrangement document's canonical JSON (see below). Two exports with the samedocHashwere made from the same arrangement.
Neither survives a project import on another machine: the history isn't carried, and ids are renewed. Audio can also change without a new revision, through a re-render of a scene's instruments or a mix change. That's what files[].sha256 is for.
Change detection
Three hashes tell a consumer in one comparison what changed between two exports of a song:
| Hash | Covers | When only this one changed |
|---|---|---|
timingHash (1.1) | tempo, sample rate, the last bar's end, each section's id, label, meter, bar count and frames | none (it changing means: re-time everything) |
arrangementHash (1.1) | timingHash, plus what plays: every layer's runs (bars, loop position, level, gain envelope, fades), the placed effects that sound, and the silenced layers | refresh what reacts to layers; the timing is the same |
files[].sha256 | each audio file, byte for byte | swap the audio; nothing about the arrangement moved |
scoreHash (1.2, with score) | timingHash, plus the score: the sections' scenes and roles, and every layer's role, bucket, drum class, silence, spans, effects and notes (names aside) | refresh what follows the layers or notes; the timing is the same |
Each hash is the SHA-256, in lower-case hex, of the UTF-8 bytes of the canonical JSON of a payload. Canonical JSON has object keys sorted (by code unit) at every level, no whitespace, and non-ASCII characters left unescaped. The payloads are versioned ("v": 1). A later format version that hashes more adds a new hash rather than widening these: 1.2's scoreHash covers the notes.
Timing payload, version 1:
{
"v": 1,
"sampleRate": 48000,
"bpm": 120,
"endFrame": 1440000,
"sections": [
{ "id": "…", "label": "verse 1", "meter": "4/4", "lengthBars": 4, "startFrame": 0, "lengthFrames": 384000 }
]
}
Arrangement payload, version 1:
{
"v": 1,
"timingHash": "…",
"silent": ["<layerId>", "…"],
"layers": [
{
"layerId": "…",
"runs": [
{
"section": "<instanceId>", "enterBar": 1, "exitBar": null, "offsetBeats": 0,
"gainDb": 0, "gainPoints": [],
"fadeIn": { "beats": 0, "curve": "equalPower" }, "fadeOut": { "beats": 0, "curve": "equalPower" }
}
]
}
],
"treatments": [
{ "section": "<instanceId>", "layerId": "…", "type": "fill_roll8", "bar": 4, "params": {}, "gainDb": 0 }
]
}
layersis sorted bylayerId, each layer'srunsin timeline order. A run is one stretch of a section where the layer plays:enterBaris its first bar,exitBarthe first bar it doesn't play (null: to the section's end), andoffsetBeatsthe loop position at its first downbeat.gainDbincludes the scene's gain.gainPointsis the envelope over the run in section-relative quarter notes, dB on top ofgainDb.silentlists muted or not-soloed layers; their runs are listed as if audible.treatmentsare the placed effects that sound, in timeline order.
Score payload, version 1 (1.2):
{
"v": 1,
"timingHash": "…",
"sections": [{ "id": "<instanceId>", "sourceSceneId": "…", "role": null }],
"layers": [
{
"layerId": "…", "role": "kicks", "bucket": "drums", "drumClass": "kick", "drumMap": null,
"silent": false, "spans": [], "treatments": [], "notes": null
}
]
}
spans,treatmentsandnotesare the score's own. Absent fields arenull(silent:false), and names are left out.- The tempo map, meter map and bars derive from the timing, which
timingHashcovers. notesisnullwhen the export left notes out. ComparescoreHashonly between exports made with the sameoptions.scoreNotes.
Every fixture in fixtures/semantics/ carries its payloads and hashes.
Privacy
By default an export records where it was written: options.destination, master.input.path / master.output.path, abletonBundle and some warnings hold absolute paths, which include the user's home folder.
With options.pathFree: true (1.1), the export leaves every absolute path out:
options.destination,master.input.pathandmaster.output.pathare omitted;abletonBundlekeeps only the folder's name;nullTest.mismatchesandwarningsare made relative to the bundle, with the home folder written as~.
files[].file is always bundle-relative and, from 1.1, always uses / as separator. Plugin states are never exported. Stems are optional: an export may hold only the master.
The score (1.2) holds no paths. Its notes are the layers' MIDI, written only when the user asks (options.scoreNotes). Like stems, they are optional.
Other fields
| Field | Meaning |
|---|---|
renderedAt | when the export started (UTC) |
project.name, project.bpm | the project's name and tempo |
arrangement.name | the arrangement's name |
engine.sha256 | the audio engine build that rendered the files |
options | the export as it was asked for |
name | the export's name: the base of its folder and file names |
master | the master's loudness before and after mastering: master.output.integratedLufs and master.output.truePeakDbtp are the delivered file's. master.applied holds the mastering settings, master.resampled the rate change. |
nullTest | stems minus mix, when stems were rendered: pass means they sum to the mix within thresholdDb |
monoContent | the mix's left and right channels are identical and not silent |
buses[] | the mix buses rendered, with their effects' names and versions (no settings) |
staleStemsRendered | how many layer stems were re-rendered before the export |
warnings | anything that went wrong but didn't stop the export |
The schemas give every field's type and whether it is required.
Validating
The reference reader and its sas-export command, in reader/, check a file against the schema of its version and recompute the hashes it carries that its content covers (timingHash, and scoreHash with a score):
cd reader && npm install && npm run build
node dist/bin.js validate "My Song - export.json" # exit 0 when valid, 1 with the errors listed
node dist/bin.js info "My Song - export.json" # a summary; --json for the export as read
From code, readExport(path) validates first, then returns the tempo and meter maps, bars, sections, layers and notes. The contract, with the reader and every fixture, is published at https://github.com/shiehn/sas-arrangement-export-contract (MIT).
With any JSON Schema 2020-12 validator:
import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(schema); // the schema of the file's exportVersion
if (!validate(exportJson)) console.log(validate.errors);
The v1 schema (…/v1/schema.json) is the latest 1.x. A file of any 1.x version validates against it, so a reader can use it without picking the file's minor.
Signals & Sorcery's own tests validate the exports it writes against the schema of the version they declare.