Skip to content

Module JSON Schema

A module is a composition saved as JSON. It has exactly two top-level keys: a baseNote object and a notes array. Every musical value is stored as an expression string, never as a computed number.

json
{
  "baseNote": { "...": "expressions" },
  "notes": [ { "id": 1, "...": "expressions" } ]
}
FieldTypeRequiredDescription
baseNoteobjectyesThe root reference note. It is note id 0.
notesarrayyesFlat array of note objects. Order does not matter; the app sorts by id on export.

There is no version, name, author, tempo map, measures[], or parentId field. A module has no metadata at all: the library takes a module's display name from the library manifest or from the uploaded file's name, never from the file's contents. Parentage is reconstructed from the expressions themselves.

notes is mandatory in practice

The structure check on Load Module tolerates a file with no notes key, but the loader then throws and you get a red Error loading module: … banner. Always include notes, even if empty.

A real module file

This is public/modules/intervals/3-2.json, shipped with the app in full:

json
{
  "baseNote": {
    "frequency": "263",
    "startTime": "0",
    "tempo": "60",
    "beatsPerMeasure": "4"
  },
  "notes": [
    {
      "id": 1,
      "startTime": "base.t",
      "duration": "beat(base)",
      "frequency": "(3/2) * base.f",
      "color": "rgba(242,167,27,0.7)"
    }
  ]
}

One note, a perfect fifth above the BaseNote, one beat long, starting where the BaseNote starts.

The baseNote object

Every field is optional. An omitted field falls back to the class default below — not to the values in the default module you see when you open the app (263 Hz / 100 BPM).

FieldTypeDefault if omittedNotes
frequencyexpression string440 HzShipped modules use 263.
startTimeexpression string0 sShipped modules use 0.
tempoexpression string60 BPMIn beats per minute.
beatsPerMeasureexpression string4The numerator of the time signature.
measureLengthexpression string60 / tempo × beatsPerMeasureFive of the six scale-system modules are the only shipped modules that set it explicitly, as beat(base) * base.bpm.
colorCSS color stringnoneAccepted by the loader and the exporter. No shipped module uses it.
instrumentinstrument namenone → the audio.defaultInstrument settingSee Instruments.

The BaseNote has no duration

duration is not among the BaseNote's defaults, and none of the 79 shipped modules sets baseNote.duration. base.d compiles, but it reads an empty expression. Do not write one, and do not depend on it.

The notes array

FieldTypeRequiredNotes
idintegeryes0 ≤ id ≤ 65535. 0 is reserved for the BaseNote. Duplicates are rejected by the library validator.
startTimeexpression stringin practice, yesSeconds from time zero.
durationexpression stringnoSeconds. Absent = a measure bar.
frequencyexpression stringnoHz. Absent (with a duration present) = a silence.
tempoexpression stringnoPer-note override.
beatsPerMeasureexpression stringnoPer-note override — this is how measure bars carry a meter change.
measureLengthexpression stringnoPer-note override.
colorCSS color stringnoe.g. "rgba(242,167,27,0.7)".
instrumentinstrument namenoInherited along the frequency chain when absent.

The six expression keys are exactly startTime, duration, frequency, tempo, beatsPerMeasure, measureLength. The three non-expression keys are id, color, instrument. Anything else in a note object is ignored.

Note kinds are inferred, not declared

There is no type field. What a note is follows from which expressions it has:

KindRuleDrawn as
NotestartTime + duration + frequencyA filled bar at its pitch
SilencestartTime + duration, no frequencyA dashed outline, no sound
Measure barstartTime, no duration, no frequencyA vertical dashed line across the workspace

A chain of measure bars, each starting one measure after the last, is a measure chain:

json
{ "id": 1, "startTime": "base.t" },
{ "id": 2, "startTime": "[1].t + measure([1])" },
{ "id": 3, "startTime": "[2].t + measure([2])" }

Expression strings

Expressions are text. They are compiled to bytecode, never eval'd — nothing in the load path uses eval() or new Function().

Two formats compile to the same bytecode and may be mixed freely in one file. The format is detected per expression string.

DSL — the primary format. Every shipped module uses it for its notes:

base.f * (3/2)          # a fifth above the base
[1].t + [1].d           # start when note 1 ends
[1].t + measure([1])    # one measure after note 1 starts
beat(base) * (3/4)      # a dotted eighth
2^(1/12)                # one 12-TET semitone

See the expression syntax reference for the full grammar.

Legacy JavaScript syntax

Method-chain expressions still load, and still round-trip verbatim:

javascript
module.baseNote.getVariable('frequency').mul(new Fraction(3, 2))
module.getNoteById(1).getVariable('startTime')
new Fraction(60).div(module.findTempo(module.baseNote))

Format detection

A string is compiled as DSL when it contains [N]. or base. anywhere, or starts with a fraction literal like (3/2) or a call to tempo(, measure(, beat(. It is compiled as legacy when it contains new Fraction(, module., .getVariable(, or a .mul(/.div(/.add(/.sub(/.pow( chain.

Position matters for the helper calls only in the first routing pass: beat(base) * 2 is sniffed as DSL directly, while 2 * beat(base) has no leading marker and is routed to the legacy parser first. That is no longer fatal — when the legacy parser fails, the compiler retries the string as DSL, so 2 * beat(base) still compiles. See the beat unit.

A string that is pure arithmetic with no references440, 263, 2 * 263, (1/2) * 263 — is routed to the DSL compiler directly.

An unparseable expression is rejected with an error

If neither compiler can read a string, compile() logs a console.error naming the expression and throws — there is no silent constant-0 fallback. In the note widget the message appears under the Save button; the syntax validators return valid: false; npm test rejects a shipped module containing one. On a file load the affected property is left unset rather than zeroed, so the note falls back to its defaults.

Colors

Both #rrggbb hex and rgba() are accepted, but every shipped module uses rgba() with alpha, because alpha is visible:

json
"color": "rgba(242,167,27,0.7)"
"color": "hsla(258, 70%, 60%, 0.7)"

New notes created in the app get a random hsla(<random hue>, 70%, 60%, 0.7).

The library validator whitelists #rgb, #rrggbb, #rrggbbaa, rgb(), rgba(), hsl(), hsla() and the ~140 named CSS colors. Anything else is rejected.

Instruments

instrument is a plain name string. The nine built-ins:

NameKind
sine-waveSynth
square-waveSynth
sawtooth-waveSynth
triangle-waveSynth
organSynth — additive harmonics
vibraphoneSynth
fm-epianoSynth — FM electric piano (carrier + modulator)
pianoMultisampled (VSCO2 Community Edition, CC0)
violinMultisampled (VSCO2 Community Edition, CC0)

Instrument inheritance follows the frequency chain. When a note has no instrument of its own, the app reads its frequency expression, finds the note it references ([N].f, or base.f), and asks that note for its instrument — recursively. If nothing along the chain pins an instrument, the note falls back to the global audio.defaultInstrument setting (default sine-wave).

This is why most scale-system modules pin "instrument": "sine-wave" on their BaseNote: it fixes the timbre for the whole module instead of letting it follow the listener's default.

What Save Module actually writes

Save Module (in the top-bar + menu) downloads a file named module.json, pretty-printed with 2-space indentation. Three things about that file surprise people:

  1. It is reindexed. Measure bars are renumbered first (sorted by evaluated startTime), then the remaining notes (also by startTime), starting at id 1. Every [N] reference is rewritten to match. Ids in the saved file will generally differ from the ids you saw on screen. There is no byte-stable round trip.
  2. Expression source text is preserved verbatim. Saving does not convert legacy expressions to DSL, and it does not convert DSL to legacy.
  3. The BaseNote gains a measureLength, even if your source file omitted it. It is emitted in DSL form — the class default. Round-tripping the file above produces:
json
"measureLength": "beat(base) * base.bpm"

A pure-DSL source file therefore round-trips as pure DSL.

Limits and validation

Different entry points apply different checks. This is the exhaustive picture.

Entry pointSize capStructureExpressionsColors
Load Module (file → workspace)3 MBdepth ≤ 20, ≤ 10 000 notes
Library upload (the + placeholder in the module bar)fullcheckedchecked
Load UI importfull, per embedded modulecheckedchecked
Copy to Modules (group selection → library)fullcheckedchecked
Drag-drop of a library icon onto a notesniff only

Hard limits, wherever a module is read:

LimitValue
Max file size (Load Module)3 MB
Max notes10 000
Max JSON nesting depth (Load Module)20
Valid note idinteger, 0 – 65 535 (ids are encoded as u16 in the evaluator's bytecode)
Max expression length10 000 characters
Note ids blocked outright__proto__, constructor, prototype

Toasts you can hit on load: Module file too large (max 3MB), Invalid module file structure, Module loaded successfully.

The id ceiling exists because a reference is encoded as a 16-bit integer in the evaluator's bytecode. The loader rejects (and skips) any note whose id is above 65 535, and both expression parsers enforce the same range at compile time — [70000].f is a parse error (Note IDs must be integers between 0 and 65535), not a silently truncated reference to a different note.

Expression strings are additionally screened for dangerous-looking patterns (eval(, Function(, fetch(, document., window., __proto__, <script, javascript:, …) at the library-upload and Load-UI entry points. Malformed expressions of either syntax — DSL or a broken legacy method chain — are caught by the compiler itself, which throws instead of guessing (see above).

The library manifest

The module library is described by one top-level file, public/modules/library.json, a v2 manifest:

json
{
  "version": 2,
  "sections": [
    {
      "id": "intervals",
      "label": "Intervals",
      "items": [
        {
          "file": "intervals/1-1.json",
          "name": "Unison",
          "ratio": "1/1",
          "cents": 0,
          "family": "3-limit",
          "tags": ["P1", "unison", "prime"]
        }
      ]
    }
  ]
}
Item fieldTypeRequiredUsed for
filepath relative to public/modules/yesFetching the module
namestringyesThe icon's label and the search index
ratiostring "n/d"noThe fraction shown on the icon; checked by npm test
centsnumbernoShown when Show cents is on; checked by npm test
familystringnoe.g. 3-limit, 5-limit
tagsstring[]noMatched by the module bar's search field

Shipped sections and counts:

Section idLabelModules
intervalsIntervals46
chordsChords11
progressionsProgressions8
melodiesMelodies7
scale-systemsScale Systems6
customCustom1

79 modules in total. npm test validates every one of them: structure, expression syntax, self-containment (every [N] reference resolves inside the same file), finite evaluation, and — for single-note interval modules — that the evaluated frequency really is ratio × base and that cents really is 1200·log2(ratio).

Self-containment is what makes a library module droppable: on import, its note id 0 is remapped onto the note you dropped it on, and its internal ids are renumbered above the current maximum.

A per-category index.json loader still exists as a fallback for the pre-v2 layout. The v2 manifest is authoritative.

The other saved JSON: ui-state.json

Save UI (in the module bar's footer) downloads a different file — ui-state.json. It is the library layout, not a composition: sections and their labels, which icons sit where, which are collapsed, the drop mode (Start or End, stored under the dropMode key), and the full JSON of any module you uploaded (uploads have no re-fetchable path, so their data is embedded; built-ins are stored by file path).

Do not confuse the two. A ui-state.json will not load as a module, and a module.json will not load as a UI state.

Where the app keeps things between sessions

localStorage keyContents
rmt:moduleSnapshot:v1The current composition, in module-JSON shape. Written on every undoable edit, on tab-hide, and on unload. Loaded on boot instead of the default module.
ui-stateThe library layout (the Save UI payload).
rmt:settings:v1The settings tree.

There is no "unsaved changes" prompt and no named-project concept: the app always resumes the last state.

See also

Released under the MIT License