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.

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, or sas arrangement export. See Undo and export. The score is written by default (score: false leaves 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.

Everything here is stated from the code that writes and plays the export, not from listening.


Versions

  • schema is always "sas-arrangement-export/1". It names the format family.
  • exportVersion is the format's own semantic versionopen in new window. A file without it is 1.0.0; files written to this document carry 1.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.json is the latest 1.x. The same files ship with the arrangement contract package, in export/schema/.
  • contractVersion is 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. Use exportVersion to 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:

FileWhen
<name> - export.jsonalways (this document)
<name> - Master (<Preset> <LUFS> LUFS, <bit depth>[, <rate> kHz]).wavwhen a master was asked for
<name> - Mix (32f).wavwhen the unmastered mix was asked for
Stems/NN <label>.wavstems: one per mix bus ("Drums"), then one per layer outside any bus ("Kick (verse)")
Editable stems/NN <label>.waveditable 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, lengthSeconds and nullTest.frames are at sampleRate, 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). Then master.resampled names both rates, and the master's own frame count is round(frames × toRate / fromRate). Use the export's sampleRate frames to place sections, and scale by toRate / fromRate if 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 below floorDbfs (−90 dBFS), never before the song end and at most capSeconds (10 s) after it.
  • tail.mode: "fixed": the files end seconds after 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.
  • instanceId is stable across saves, re-exports and a project import, as long as the section exists.
  • label is 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:

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 n of the section plays loop bar n (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 phaseOffsetBeats shifts that anchor by its value (quarter notes).
  • A lane's phaseSegments override it inside the section. From a segment's fromBar on (until the next segment), the position at each downbeat is offsetBeats + (bar − fromBar) × beats per bar, wrapped to the loop. A segment replaces both the anchor rule and phaseOffsetBeats there; 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 fadeInBeats of the lane in the section where the run starts. Its fade-out is the fadeOutBeats of 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 gainDb sets 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 gainEnvelope is applied in the audio. It is dB points at quarter notes from the section's downbeat, added to gainDb. 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 ascending altOrder.
  • 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 assumeWhat actually happensProof
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 at arrangement.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.

  • name is the user's track name.

  • role is the instrument role, or null (e.g. an audio loop). The known roles are kicks, snares, hats, perc, cymbals, drums (a whole kit), bass, 808s, keys, pads, chords, strings, brass, winds, plucked, atmospheres, lead, arp, bells, vocals, fx and unclassified.

  • bucket is 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
  • drumClass marks a drum-kit piece: kick, snare, hat, perc or cymbal, from its role. A whole kit on one layer (role drums) is "kit", and with notes its drumMap gives 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 else unknown. 808s are bass and have no drumClass.

  • 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 / startFrame start it, and endBar / endFrame are 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.
  • gainDb is the lane's gain plus its scene's gain. gainPoints is its envelope in frames, dB on top of gainDb (Gain).
  • fadeIn / fadeOut are {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.
  • frame is 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.
  • pitch is 0–127 (MIDI; 60 is middle C) and velocity 1–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

IdStable across
arrangement.instances[].instanceIdsaves, 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.idsaves and re-exports on the same machine. New on another machine.
arrangement.planIdnothing: 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:

  • seq is 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.
  • docHash is the SHA-256 of the arrangement document's canonical JSON (see below). Two exports with the same docHash were 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:

HashCoversWhen only this one changed
timingHash (1.1)tempo, sample rate, the last bar's end, each section's id, label, meter, bar count and framesnone (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 layersrefresh what reacts to layers; the timing is the same
files[].sha256each audio file, byte for byteswap 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 }
  ]
}
  • layers is sorted by layerId, each layer's runs in timeline order. A run is one stretch of a section where the layer plays: enterBar is its first bar, exitBar the first bar it doesn't play (null: to the section's end), and offsetBeats the loop position at its first downbeat.
  • gainDb includes the scene's gain. gainPoints is the envelope over the run in section-relative quarter notes, dB on top of gainDb.
  • silent lists muted or not-soloed layers; their runs are listed as if audible.
  • treatments are 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, treatments and notes are the score's own. Absent fields are null (silent: false), and names are left out.
  • The tempo map, meter map and bars derive from the timing, which timingHash covers.
  • notes is null when the export left notes out. Compare scoreHash only between exports made with the same options.scoreNotes.

Every fixture in fixtures/semantics/open in new window 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.path and master.output.path are omitted;
  • abletonBundle keeps only the folder's name;
  • nullTest.mismatches and warnings are 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

FieldMeaning
renderedAtwhen the export started (UTC)
project.name, project.bpmthe project's name and tempo
arrangement.namethe arrangement's name
engine.sha256the audio engine build that rendered the files
optionsthe export as it was asked for
namethe export's name: the base of its folder and file names
masterthe 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.
nullTeststems minus mix, when stems were rendered: pass means they sum to the mix within thresholdDb
monoContentthe mix's left and right channels are identical and not silent
buses[]the mix buses rendered, with their effects' names and versions (no settings)
staleStemsRenderedhow many layer stems were re-rendered before the export
warningsanything 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/open in new window, 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-contractopen in new window (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.

Last Updated:
Contributors: shiehn