Skip to content

Expression Compiler ​

Every note property in RMT Compose is stored as a text expression. Before it can be evaluated it is compiled to a compact bytecode that a stack VM executes. Nothing is ever passed to eval() or new Function().

text → [ routing ] → DSL lexer → parser → compiler ──┐
                  └→ legacy parser → emitter ────────┴→ BinaryExpression (bytecode + deps)

Two source formats compile to the same bytecode:

FormatExampleWhere it lives
DSL (primary)base.f * (3/2)src/dsl/ — lexer, parser, compiler, decompiler
Legacy method chainmodule.baseNote.getVariable('frequency').mul(new Fraction(3, 2))src/expression-compiler.js

Every shipped module JSON is DSL. The legacy parser exists so old modules still load.

Entry point ​

ExpressionCompiler.compile(textExpr, varName) (src/expression-compiler.js:54) is the only door in. Note._setExpression() calls the module-level singleton compiler exported at expression-compiler.js:982.

The routing is worth reading closely:

  1. Cache probe. On a hit the key is deleted and re-inserted (an LRU touch) and a clone is returned (:57-63).
  2. isDSLSyntax(textExpr) → compileDSL(). If the DSL compiler throws, it logs a warning and falls through to the legacy parser (:67-76).
  3. Legacy path → parse() → emitBytecode().
  4. If the legacy parser throws, it retries compileDSL() (:89-97). The routing check in step 2 is a regex sniff, not a parse, so it can misclassify a DSL expression as legacy; this retry is what lets a misclassified DSL string still compile.
  5. If both parsers fail, it logs Failed to compile expression: … via console.error and throws an Error whose message carries both parsers' failures (:105-108). There is no constant-0 fallback: interactive callers (the note widget's Save, the validators) surface the message, while the load paths catch per-note and leave the property unset.

compile() always returns binary.clone(), never the cached instance.

The compile cache ​

javascript
const COMPILE_CACHE_MAX = 4000;  // src/expression-compiler.js:23

A plain Map keyed on the raw expression text, used as an LRU: _cacheSet() evicts cache.keys().next().value — the oldest insertion — when at cap and the key is new (:35-42). The cap exists because drag and resize commits mint a fresh fraction string on every commit, so an unbounded cache grows for the life of the session.

The cache is per-instance, not global

src/note.js uses the exported singleton, but src/modals/validation.js, src/modals/variable-controls.js, src/modals/note-creation.js, src/utils/safe-expression-validator.js and src/utils/simplify.js each construct their own new ExpressionCompiler(). That is six independent 4000-entry caches in a running app. clearCache() only clears the one you call it on.

Format detection — isDSLSyntax() ​

src/dsl/index.js:23-72. Returns false for non-strings and blanks, then checks in this exact order:

  1. DSL if any of: ^[N]., ^base., a leading fraction literal ^(n/d), a leading tempo(/measure(/beat(, [N]. anywhere, or base.<lowercase> anywhere.
  2. Legacy if any of: new Fraction(, module.getNoteById, module.baseNote, .getVariable(, .mul|.div|.add|.sub|.pow|.neg(, module.findTempo|findMeasureLength.
  3. DSL if the whole string is reference-free arithmetic — /^[-+*\/^().\d\s]+$/. This matches 263, 2 * 263, (1/2) * 263.
  4. Otherwise legacy (the safe default).

Rule 3 is not cosmetic. The BaseNote's frequency references nothing — in the default module it is the literal 263 — so a reference-free edit to it is the common case. Legacy cannot spell infix arithmetic without new Fraction( or a .mul() chain:

ExpressionLegacy parser yields
2 * 263throws (parseAtomic refuses to guess)
789/2throws
526const 526/1 — a bare number is the one case legacy handles

Rule 3 sends all three to the DSL compiler directly. Even without it these would still compile — a legacy-parser throw falls through to the DSL retry in compile() — but the direct route skips a parse-and-fail round trip on the hottest edit in the app.

The DSL pipeline ​

FileExportsRole
src/dsl/lexer.jstokenize(source)text → tokens. # starts a comment to end of line.
src/dsl/parser.jsparse(tokens)recursive descent → AST. The grammar is written out verbatim at parser.js:6-17.
src/dsl/ast.jsNodeType, collectDependencies, referencesBasenode factories + AST walks
src/dsl/compiler.jscompile(ast, sourceText)AST → BinaryExpression
src/dsl/decompiler.jsdecompile(binaryExpr)bytecode → DSL text
src/dsl/constants.jsTokenType, PropertyMap, HelperFunctions, Precedencethe tables
src/dsl/errors.jsDSLLexerError, DSLParseError, DSLCompileError, ErrorMessagestyped errors with a userMessage
src/dsl/index.jsisDSLSyntax, compileDSL, decompileToDSL, validateDSLthe public API
src/dsl/simplify.jssimplifyDSL, scaleDSLDSL-native canonicaliser (see SymbolicPower)

compileDSL('') returns bytecode for the constant 0/1 rather than throwing (dsl/index.js:84-92).

Properties ​

src/dsl/constants.js:42-69. These aliases and no others:

CanonicalAccepted spellingsCanonical short form
frequencyf, freq, frequencyf
startTimet, s, start, startTimet
durationd, dur, durationd
tempotempotempo
beatsPerMeasurebpm, beatsPerMeasurebpm
measureLengthml, measureLengthml

[0].prop is parsed as base.prop — note id 0 is the BaseNote (parser.js:262-267).

Helper functions ​

Exactly three (constants.js:86), arity 1, and the argument must be a bare note reference — [N] or base, never an expression (parser.js:308-358).

CallLowers to
tempo(x)LOAD_REF x, VAR.TEMPO — byte-for-byte identical to x.tempo (dsl/compiler.js:211-222)
measure(x)LOAD_REF x, VAR.MEASURE_LENGTH — identical to x.ml (:228-239)
beat(x)LOAD_CONST 60, tempo(x), DIV (:245-253)

beat(base) is the idiomatic way to write one beat in seconds. Do not hand-write 60 / tempo(base).

Bytecode format ​

src/binary-note.js:9-32:

javascript
export const OP = {
  LOAD_CONST:      0x01,
  LOAD_REF:        0x02,
  LOAD_BASE:       0x03,
  LOAD_CONST_BIG:  0x04,

  ADD:             0x10,
  SUB:             0x11,
  MUL:             0x12,
  DIV:             0x13,
  NEG:             0x14,
  POW:             0x15,

  FIND_TEMPO:      0x20,   // dead — see below
  FIND_MEASURE:    0x21,   // dead
  FIND_INSTRUMENT: 0x22,   // dead

  DUP:             0x30,   // dead
  SWAP:            0x31,   // dead
};

FIND_*, DUP and SWAP are never emitted

No compiler produces them. The legacy emitFindTempo / emitFindMeasure (expression-compiler.js:685-716) lower to a plain LOAD_BASE/LOAD_REF with VAR.TEMPO or VAR.MEASURE_LENGTH, and the DSL compiler does the same. FIND_TEMPO, FIND_MEASURE, DUP and SWAP are implemented in the VM but unreachable from any compiled expression; FIND_INSTRUMENT is not even handled there. Treat them as reserved bytes.

Instruction encoding ​

LOAD_CONST      [0x01][num: i32 BE][den: i32 BE]                        9 bytes
LOAD_REF        [0x02][noteId: u16 BE][varIndex: u8]                    4 bytes
LOAD_BASE       [0x03][varIndex: u8]                                    2 bytes
LOAD_CONST_BIG  [0x04][sign: u8][numLen: u16][num…][denLen: u16][den…]  variable

ADD SUB MUL DIV NEG POW                                                 1 byte

Operands are pushed before the opcode — it is a stack machine, nothing nests.

Variable indices ​

The export is VAR, with a VAR_NAMES reverse map (binary-note.js:35-52):

javascript
export const VAR = {
  START_TIME: 0,
  DURATION: 1,
  FREQUENCY: 2,
  TEMPO: 3,
  BEATS_PER_MEASURE: 4,
  MEASURE_LENGTH: 5,
};

Constant emission ​

emitConstant() (expression-compiler.js:590) and emitFraction() (dsl/compiler.js:87-118) both GCD-normalise the fraction in BigInt (bigGcd from src/utils/fraction-num.js), move the sign to the numerator, then pick the opcode by magnitude: LOAD_CONST if numerator and denominator both fit in i32 (-2147483648 … 2147483647), otherwise LOAD_CONST_BIG. There is no constBig AST node — the choice is made at emit time.

Number literals are exact at any size in both parsers: the digits are parsed straight into a BigInt fraction by decimalStringToBigFraction() (src/utils/fraction-num.js:36), never through parseFloat. A decimal literal is exact as written — 3.14159 is 314159/100000 and 0.333333 is 333333/1000000, not 1/3.

LOAD_CONST_BIG carries BigInts, and they survive

The evaluator decodes the variable-length BigInts straight into a pooled BigInt-backed fraction (binary-evaluator.js:975-987), so a big constant keeps every digit through parse → emit → decode → evaluate → decompile — scripts/test-exactness.mjs (part of npm test) proves it with a ~98-digit literal. The old path routed the decode through a double-backed Fraction constructor, which silently rounded anything past 2^53: the encoding preserved the bytecode but not the precision.

BinaryExpression ​

binary-note.js:77-315. new BinaryExpression(initialSize = 64) — the constructor takes a byte count, not a buffer.

FieldTypeNotes
bytecodeUint8Arraystarts at 64 bytes, doubles on demand
lengthnumberbytes written
dependenciesUint16Arraystarts at 16 entries, doubles
depCountnumber
sourceTextstringthe original text, kept for round-trip
referencesBasebooleanset by LOAD_BASE emission

Key methods: addDependency(), getDependencySet(), getPropertyDependencies() (scans the bytecode into a Map<noteId, Set<varIndex>> — this is where the dependency graph gets its property-level edges), referencesProperty(), isEmpty(), clone().

A LOAD_BASE does not add note 0 as a dependency. Recording it would give the BaseNote a self-cycle; the referencesBase flag is tracked separately instead (comments at expression-compiler.js:665-668).

The legacy parser ​

There is no lexer and no position cursor. parse() is a recursive string splitter:

parse(expr)
  └ splitAddSub    → sum    { terms: [{ sign, node }] }
      └ parseProduct
          └ splitMulDiv → product { base, operations: [{ op, node }] }
          └ splitPow    → power   { base, exponent }
              └ parseAtomic

parseAtomic (expression-compiler.js:173-328) regex-matches, in order: new Fraction(n[, d]), module.baseNote.getVariable('x'), module.getNoteById(N).getVariable('x'), module.findTempo(ref), module.findMeasureLength(ref), the beat pattern new Fraction(60).div(module.findTempo(ref)), a bare number, a .pow() chain, a new Fraction(...).mul(...) chain, a bare variable name — and if none match, it throws (Unable to parse expression fragment: …), which is what lets compile() fall through to the DSL retry instead of guessing.

Both parsers enforce the bytecode's u16 note-id range at parse time: a reference to an id above 65535 throws — Note id N out of range (max 65535) here (expression-compiler.js:215-216), Note IDs must be integers between 0 and 65535 in the DSL (dsl/parser.js:253). It used to be silently truncated into a reference to a different note when LOAD_REF wrote its u16.

The legacy AST node types (emitBytecode, :535-584):

javascript
{ type: 'const',       num, den }
{ type: 'baseRef',     varName }
{ type: 'noteRef',     noteId, varName }
{ type: 'findTempo',   ref }              // ref = {kind:'base'} | {kind:'note', id}
{ type: 'findMeasure', ref }
{ type: 'beatUnit',    ref }
{ type: 'sum',         terms:      [{ sign, node }] }
{ type: 'product',     base, operations: [{ op, node }] }
{ type: 'power',       base, exponent }

The DSL AST is a different set entirely (src/dsl/ast.js).

Decompilers ​

There are two, and they are not interchangeable.

ClassSingletonEmits
ExpressionDecompiler (expression-compiler.js:784)decompilerlegacy method-chain text
DSLExpressionDecompiler (:991)dslDecompilerDSL text (delegates to decompileToDSL)

ExpressionDecompiler.decompile() returns binary.sourceText verbatim if it is set (:793-795), so for anything compiled from text it is a pass-through; its stack-based path only runs for synthesised expressions.

The DSL decompiler (src/dsl/decompiler.js) is a stack machine over the bytecode that re-emits DSL, inserting parentheses from the Precedence table, printing fractions as (n/d) and integers bare. Constants come out of the bytecode as BigInts and are stringified directly (pushConst, :207-213), so a big constant is printed digit-exact — it used to be coerced through Number(), which rounded and could emit unparseable scientific notation. It pattern-matches 60 / <ref>.tempo back into beat(<ref>) (:113-119, :236-240).

The note widget's Raw: box always shows DSL: convertToDSLDisplay() decompiles the note's compiled bytecode regardless of the stored format (src/modals/variable-controls.js:87-101).

Round-trip is lossy in specific, harmless ways ​

You typeIt is saved as
tempo(base)base.tempo
measure([2])[2].ml
beat(base) * (1/4)beat(base) * (1/4) — survives
base.freq, base.s, base.durbase.f, base.t, base.d
[0].fbase.f
0.5 * base.f(1/2) * base.f
3.14159(314159/100000) — decimals are exact as written
base.f # a commentbase.f — comments are dropped
2^-12^(-1)

tempo() and measure() are write-only sugar: they compile to the same bytecode as the property form and never come back. beat() is the only helper the decompiler reconstructs.

Validation ​

The compiler refuses garbage by throwing, but validation is still a separate gate — it is what checks self-references and cycles, and what turns a parser failure into a structured result.

GateWhereWhat it does
UI editsvalidateExpression(), src/modals/validation.js:13self-reference check, validateDSL(), circular-dependency BFS, then compileDSL() to prove it yields bytecode
DSL syntaxvalidateDSL(), src/dsl/index.js:142tokenize + parse; returns {valid, error} with error = the DSLError.userMessage
JSON importvalidateExpressionSyntax(), src/utils/safe-expression-validator.js:30rejects strings over 10 000 chars and a blocklist (eval(, Function(, import(, document., __proto__, <script, …), then proves the string compiles — a compiler throw comes back as {valid: false, error}

Nothing anywhere calls eval() or new Function() on an expression.

The Save handler catches validation errors and shows them inline: showError() prints the message in red under the Save button and gives the raw input a red border, cleared on the next edit or save attempt (src/modals/variable-controls.js:138-153, :998). The user-grade error strings in src/dsl/errors.js are what the user actually reads.

Worked example ​

Input (DSL):

[1].f * (3/2)
Legacy JavaScript syntax
javascript
module.getNoteById(1).getVariable('frequency').mul(new Fraction(3, 2))

Bytecode (verified against the real compiler):

0x02 0x00 0x01 0x02                        LOAD_REF  note=1, var=2 (frequency)
0x01 0x00 0x00 0x00 0x03 0x00 0x00 0x00 0x02   LOAD_CONST 3/2
0x12                                       MUL

dependencies = [1], referencesBase = false, getPropertyDependencies() = {1 → {2}}.

Decompiled with decompileToDSL(): [1].f * (3/2) — exact round-trip.

Worked failure: [1]f is rejected ​

Omit the dot and the expression is refused with a real error rather than compiling to zero:

isDSLSyntax('[1]f')  → false     // the DSL regexes all require `[N].`
  → legacy parser → parseAtomic throws (`Unable to parse expression fragment: [1]f`)
  → compile()'s catch retries compileDSL('[1]f') → DSL parser throws too
  → compile() console.errors and throws:
    Unparseable expression: "[1]f" — legacy parser: …; DSL parser: …

In the widget the message lands under the Save button; the validators return valid: false; on a file load the property is left unset.

See also ​

Released under the MIT License