Skip to content

Module Format

A module is a composition stored as JSON. This page explains the format from an author's point of view: what you write, what the app writes back, and what it refuses.

For the exhaustive field-by-field reference, see Module JSON Schema.

Two keys, and only two

A module file has exactly two top-level keys.

json
{
  "baseNote": { },
  "notes": [ ]
}
KeyTypeWhat it is
baseNoteobjectThe root reference note. It is note id 0.
notesarrayA flat array of note objects.

That is the whole structure. There is no measures array, no version, no name, no author, no parentId. A module carries no metadata at all — the library gets a module's display name from the library manifest or from the uploaded file's name, never from inside the file.

Every musical value is an expression string, never a number. That is the point of the format: relationships survive, so changing the BaseNote moves everything that depends on it.

Here is a complete shipped module, public/modules/intervals/3-2.json:

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 base, one beat long, starting where the base starts.

The baseNote object

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

FieldDefault if you omit itNotes
frequency440 HzShipped modules use 263.
startTime0 s
tempo60 BPM
beatsPerMeasure4The numerator of the time signature.
measureLength60 / tempo × beatsPerMeasureOnly five shipped modules set it explicitly — 12/19/31-TET, Bohlen–Pierce and Mixed-Base — as beat(base) * base.bpm.
colornoneAccepted, but no shipped module uses it.
instrumentnone → the audio.defaultInstrument settingPins the timbre for everything that inherits from the base.

The BaseNote has no duration

duration is not one of the BaseNote's defaults, and no shipped module sets baseNote.duration. Do not write one, and do not write expressions that read base.d.

The notes array

FieldRequiredNotes
idyesInteger, 0 ≤ id ≤ 100000. 0 is reserved for the BaseNote.
startTimein practice, yesSeconds.
durationnoSeconds. Its absence is meaningful — see below.
frequencynoHz. Its absence is meaningful — see below.
temponoPer-note override.
beatsPerMeasurenoPer-note override. This is how a measure bar carries a meter change.
measureLengthnoPer-note override.
colornoe.g. "rgba(242,167,27,0.7)".
instrumentnoInherited 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. Any other key in a note object is ignored — including the _description field that older versions of this page suggested. Nothing reads it, and it is dropped the first time you re-save.

tempo, beatsPerMeasure and measureLength fall back to the BaseNote when a referenced note does not define them. startTime, duration and frequency do not fall back.

Note kinds are inferred, not declared

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

KindRule
NotestartTime + duration + frequency
SilencestartTime + duration, no frequency
Measure barstartTime, no duration, no frequency

All three live in the same notes array and share one id space.

A measure bar is therefore a note you left two properties off. This is the measure chain from the default module — each bar starts one measure after the previous one:

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

And this is a silence from custom/canon base.json — a quarter-beat of nothing:

json
{ "id": 1, "startTime": "base.t", "duration": "beat(base) * (1/4)",
  "color": "hsla(258, 70%, 60%, 0.7)" }

Expression strings

Expressions are text. They are compiled to bytecode and run on a stack machine — nothing in the load path uses eval() or new Function().

The DSL is the primary format. Every shipped module uses it.

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

# starts a comment that runs to the end of the line. Comments are saved: the app writes an expression back as the source text you wrote, so a comment survives the round trip.

Property names

PropertyWrite it as
frequencyf, freq, frequency
startTimet, s, start, startTime
durationd, dur, duration
tempotempo
beatsPerMeasurebpm, beatsPerMeasure
measureLengthml, measureLength

base.f and [0].f mean the same thing: the BaseNote is note 0.

Helper functions

There are exactly three, and each takes a bare note referencebase or [N], never an expression.

CallMeaning
beat(x)One beat of x's tempo, in seconds (60 / tempo)
tempo(x)x's tempo, in BPM
measure(x)x's measure length, in seconds

Use beat(base) for durations. It is what every shipped module and every expression the app writes for you uses.

Your expressions round-trip as written

Saving writes back the source text of each expression, not a regeneration of it. measure([1]) stays measure([1]), tempo(base) stays tempo(base), spacing is preserved, and only the [N] references are renumbered by the reindex. An expression is rewritten in the app's own style only when the app generates a new one for you — a drag, a resize, an arrow click, a note-length button.

Legacy JavaScript syntax

Method-chain expressions still load, and round-trip verbatim — saving does not convert them.

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

The two formats can be mixed in one file. The format is detected per expression string.

An expression neither compiler can read is rejected

The compiler logs a console.error and refuses the expression — it never silently compiles to 0. On the Load Module file path the module still loads, but the affected property is left unset (the note falls back to its defaults); the library-upload and Load-UI validators reject the file outright with the reason. If a load "succeeded" but a note looks wrong, check the browser console.

Colours

Colours are checked against a whitelist, not parsed as general CSS. Accepted forms:

FormExample
Hex#f2a71b, #fff, #f2a71bcc
RGB / RGBArgba(242,167,27,0.7)
HSL / HSLAhsla(258, 70%, 60%, 0.7)
Namedred, steelblue — about 140 CSS names

Anything else is rejected with invalid color value when the module goes through the library validator. Shipped modules use rgba() with alpha, because the alpha channel is visible in the workspace.

Instruments

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

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

A note with no instrument inherits one along its frequency chain: the app follows the note the frequency expression references, then that note's reference, and so on. If nothing in the chain pins an instrument, the note falls back to the audio.defaultInstrument setting (default sine-wave). The full lookup order, and its edge cases, are in Instruments.

That is why most of the scale-system modules put "instrument": "sine-wave" on their BaseNote — it fixes the timbre for the whole module instead of letting it follow whatever the listener set. (scale-systems/tesla.json is the exception: it pins nothing, so it plays with whatever default is set.)

Ids and reindexing

Ids are yours to choose while you hand-write a file, but they will not survive:

Save Module always reindexes. 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. Notes are then written out sorted by id.

So the ids in the file you download will generally differ from the ids you saw on screen, and from the ids you originally wrote. There is no byte-stable round trip. Reorder Module (in the + menu, behind a confirmation) applies the same renumbering to the live workspace.

One more thing changes on the way out: the BaseNote gains a measureLength even if your file omitted it, written in DSL form because that is the class default.

json
"measureLength": "beat(base) * base.bpm"

It is harmless — and because it is DSL, a pure-DSL file stays "pure DSL" when saved.

Limits

LimitValue
Max file size (Load Module)3 MB
Max notes10 000
Max JSON nesting depth (Load Module)20
Valid note idinteger, 0 – 65 535
Max expression length10 000 characters
Note ids blocked outright__proto__, constructor, prototype

The id ceiling matches the bytecode: a reference is encoded as a 16-bit integer, the loader rejects (skips, with a console warning) any id above 65 535, and an expression like [70000].f is rejected when it compiles — so an id can never wrap to a different note.

What is checked, and where

Different entry points run different checks. This trips people up, so it is worth stating plainly.

Entry pointSizeStructureExpressionsColors
Load Module (file → workspace)3 MBdepth ≤ 20, ≤ 10 000 notesnot checkednot checked
Library upload (the + tile in the module bar)fullcheckedchecked
Load UI importfull, per embedded modulecheckedchecked
Copy to Modulesfullcheckedchecked
Dragging a library icon onto a notesniff onlynot checkednot checked

Two things that are not checked anywhere at load time:

  • Dangling references. [99].f when there is no note 99 loads fine and evaluates to a fallback (440 Hz for frequency, 0 s for start time, 1 s for duration).
  • Circular dependencies. [1].f = [2].f and [2].f = [1].f load. The evaluator logs Dependency cycle detected! to the console and leaves the affected notes unevaluated.

Neither produces an error message on screen. If a note is missing or in the wrong place after a load, open the browser console.

Shipping a module in the repo

Adding a module to the library that ships with the app takes two steps.

  1. Put the file under public/modules/<section>/, e.g. public/modules/custom/my-module.json.
  2. Add an item to that section's items array in public/modules/library.json — the single v2 manifest that describes the whole library.
json
{
  "file": "custom/my-module.json",
  "name": "My Module",
  "ratio": "3/2",
  "cents": 701.955,
  "family": "3-limit",
  "tags": ["custom", "fifth"]
}
Item fieldRequiredUsed for
fileyesFetching the module; path is relative to public/modules/
nameyesThe icon's label and the search index
rationoThe fraction drawn on the icon
centsnoThe caption under the fraction, when Show cents is on
familynoThe icon's color, e.g. 3-limit, chord, melody, scale
tagsnoMatched by the module bar's search field

Then run npm test. It validates every module in the manifest: structure, expression syntax, self-containment (every [N] reference resolves inside the same file), finite evaluation — and, for a single-note interval module with a ratio, that the evaluated frequency really is ratio × base and that cents really is 1200·log2(ratio).

Self-containment is what makes a module droppable. On import, its note 0 is remapped onto the note you dropped it on; a reference that pointed outside the file would have nothing to bind to.

The per-section index.json files are a fallback, not the way in

public/modules/<section>/index.json is a bare array of filenames, read only when library.json is missing or not version 2. It knows about four sections and nothing else. Editing it will not add your module to the shipped library.

Common mistakes

Writing a measures array. There is no such key. A measure bar is a note with a startTime and nothing else. See note kinds.

Writing 60 / tempo(base) for a duration. It works — an expression that starts with a number is routed to the legacy compiler first, which cannot read it, but the failure falls through to the DSL parser — yet write beat(base) anyway: it is what the app itself writes, it skips the wasted parse, and it is the form the decompiler gives back.

Using // for comments inside an expression. The DSL comment character is #. // lexes as two division operators and fails to parse — the expression is rejected with a compile error and the property is left unset on load.

Assuming [2].f = base.f * (3/2) is valid. The DSL has no assignment operator. An expression is only ever the right-hand side; the property it belongs to is the JSON key.

Using absolute numbers for pitch and time. "frequency": "394.5" works, but the note is then frozen — it will not follow the BaseNote, and the module will not adapt when dropped onto another note. Write (3/2) * base.f.

See also

Released under the MIT License