A GL-only composition tool built around rational number relationships (ratios) instead of fixed 12-TET steps.
- Try it in your browser: https://www.rmt.world/
- Full documentation: https://docs.rmt.world/ — guides, tutorials, reference
- Contributing / continuing development? Start with the developer docs — architecture, core systems, performance, and contributing guides.
- Screenshot:
The app represents and manipulates musical structures as exact ratios and durations. Every note property — frequency, duration, start time, tempo — is an expression referencing the base note or another note, so a composition is a dependency graph, not a list of absolute values. Move one note and everything anchored to it follows. Equal-temperament systems (12-TET, 19-TET, 31-TET, Bohlen-Pierce) are expressible in the same language via power expressions.
A WebGL2 workspace handles all rendering and interaction with an instanced pipeline. There is no DOM per note.
Note properties are defined using a concise Domain-Specific Language (DSL) that compiles to binary bytecode:
base.f * (3/2) # Perfect fifth above the base note
[1].t + [1].d # Start when note 1 ends
beat(base) * 2 # Two beats long
2^(7/12) # 12-TET perfect fifth interval
The DSL replaces the verbose legacy JavaScript syntax. Both formats compile to the same bytecode and are auto-detected per expression; all shipped modules are DSL. See DSL Expression Syntax below.
- DSL compiler — expressions are lexed, parsed, validated, and compiled to compact binary bytecode. Legacy method-chain syntax (
.mul()/.pow()/...) is auto-detected and still supported. - Evaluation — a stack-based VM walks the bytecode with
fraction.js(BigInt-backed) for exact rational arithmetic at any magnitude and depth, backed by a Fraction pool to keep allocation off the drag path. Powers that resolve to an irrational value flag the note as corrupted (shown as≈in the note widget and cross-hatched on the canvas). - Dependency tracking — an inverted index gives O(1) forward and inverse lookup, split per property (startTime / duration / frequency). Drag previews move only the notes whose position actually depends on the note under the cursor.
- Incremental re-evaluation — a Kahn topological sort over the dirty set only, not the whole module.
- Rendering — every visual class (note bodies, rings, measure bars, dependency lines, glyphs) is one instanced draw call. Per-note overlay work is culled to the viewport, and the renderer redraws only when something changed: an idle canvas issues zero draw calls.
- No
eval, nonew Functionanywhere. Expressions only ever become bytecode.
Measured with the in-repo Playwright harness (headless Chromium, 1600x900, GPU-backed):
| Notes | Idle | Pan | Full redraw | renderer.sync() (per edit) |
|---|---|---|---|---|
| 5,000 | 60 fps, 0/100 frames redrawn | 60 fps | 0.77 ms | 2.0 ms |
| 20,000 | 60 fps, 0/100 frames redrawn | 60 fps | 1.75 ms | 13.1 ms |
| 100,000 | 60 fps, 0/100 frames redrawn | 60 fps | 4.27 ms | 63.3 ms |
Dragging a hub note with 4,999 direct dependents holds p99 18.0 ms; the only frame over 33 ms is the drop itself. CPU picking costs 0.31 ms per call at 100k notes. Evaluation (Node, JS evaluator) is 1–3 ms for a full re-eval at 1,000 notes and sub-millisecond for a typical mid-graph commit.
The 100k figure is about rendering, not editing: sync() is the remaining bottleneck at that scale, so panning and playing a 100k-note module is smooth but editing one is not interactive. Incremental sync() is still on the roadmap.
-
Ratio-first music model
- Notes express frequency, duration, and start time as DSL expressions over other notes (e.g.
base.f * (3/2),[1].t + [1].d) - Exact rational arithmetic; expressions compile to bytecode
- Property-coloured dependency visualization: orange = frequency, teal = startTime, purple = duration
- Corrupted (irrational) values are cross-hatched on the canvas and prefixed
≈in the widget
- Notes express frequency, duration, and start time as DSL expressions over other notes (e.g.
-
Interactive WebGL2 workspace
- Pan / zoom / pinch camera (0.1x – 10x), affine world-screen basis
- Move, resize, transpose; measure editing via drag triangles; dashed/solid measure bars
- Dependency-aware live previews during drag and resize
- Instanced rounded rectangles, glyph-atlas text, crisp fraction labels, octave guides, BaseNote indicator
- CPU picking with rounded-rect hit testing and stack cycling on overlapping notes
-
Multi-note selection
- Marquee: shift-drag on desktop, long-press then drag on touch
- Refine with shift-click (desktop) or long-press (touch) on individual notes
- Group drag in time as one undoable move; relationships inside the selection are preserved byte-for-byte
- Delete all — dependents outside the group are liberated (expressions inlined) so they keep their positions
- Copy to Modules — exports the selection as a self-contained module into the library's Custom section, rooted at its earliest note, tree intact
-
Settings panel (gear in the top bar; floating, draggable, non-modal, live — no OK/Apply)
- Appearance — 4 theme presets (Classic Orange, Slate Cyan, Mono Light, High Contrast), 15 per-token colour pickers, note height / border / corner radius
- Arrows — the ▲/▼ transposition arrows are no longer octave-only: set any ratio in
[1/16, 16](quick-pick chips for octave, fifth, fourth, major 3rd, whole tone, syntonic comma), with a live cents readout; arrows can be turned off entirely (glyphs and hit zones) - Audio — master volume, default instrument, reverb, stereo width, limiter
- Library — icon size, show cents
- Scale — X/Y density plus editable slider limits (0.001 – 1000)
- Persisted to
localStorageunderrmt:settings:v1, validated on every write
-
Audio
- Persistent Web Audio signal graph: per-instrument buses → reverb send/return → master gain → limiter
- Algorithmic reverb — the impulse response is synthesised in the browser at runtime (no audio assets); room size, decay, damping, pre-delay and wet amount are live (IR re-renders debounced)
- Optional pitch panning (low notes left, high notes right) and a peak limiter
- 9 instruments: 7 synthesized (
sine-wave,square-wave,sawtooth-wave,triangle-wave,organ,vibraphone,fm-epiano) and 2 multisampled (piano,violin) cut from the CC0 VSCO-2 Community Edition library. Sample zones are decoded lazily, only for the notes about to play. - Instruments inherit down the frequency dependency chain; a note without an explicit instrument takes its ancestor's, falling back to the global default
- Click-free ADSR, 200 ms pause fade, 20 ms stop de-click
- Loop playback — shift-click or long-press Play. Gapless at the seam: release tails and reverb ring across the loop boundary.
-
Module library (79 modules in 6 sections — see Module Library)
-
Productivity
- Undo/redo (50 entries, 12 MB cap), autosaved composition (
rmt:moduleSnapshot:v1), autosaved library layout (ui-state) - Load / save module JSON; reorder module; reset to default
- Persistent X/Y scale (time and frequency density)
- Mobile: pointer-events throughout, real viewport measurement (not
100vh), touch drag, pinch-zoom, coarse-pointer hit targets
- Undo/redo (50 entries, 12 MB cap), autosaved composition (
- Node.js 20.19+ or 22.12+ (Vite 7's requirement)
- A modern browser with WebGL2 — the workspace does not initialize without it
- (Optional) Rust +
wasm-pack, only if you want to rebuild the WASM artifacts
npm ci
npm run devVite serves on http://localhost:3000 and opens a browser.
Build and preview a production bundle:
npx vite build # what the Vercel deploy runs
npm run preview
npm run buildisnpm run wasm:build && vite buildand therefore requires Rust +wasm-packon PATH. Contributors without a Rust toolchain should runnpx vite builddirectly — the WASM artifacts are committed.
Other scripts:
| Script | What it does |
|---|---|
npm test |
Validates all 79 library modules (structure, expressions, self-containedness, finite evaluation, ratio/cents). Not a unit-test suite — the repo has no test framework. |
npm run gen:intervals |
Regenerates the 46 interval modules and patches library.json |
npm run perf:gen / npm run perf:bench |
Generate stress modules; benchmark evaluation in Node |
npm run samples:build |
Rebuilds the piano/violin samples from VSCO-2 CE (needs ffmpeg + network) |
npm run docs:dev / docs:build / docs:preview |
The VitePress docs site in docs/ |
npm run wasm:build / wasm:sync |
See below |
A Rust core (rust/, crate rmt-core) compiles to WebAssembly and implements an alternative evaluator. It is not active. Activation is opt-in via ?evaluator=wasm — without that flag the 384 KB binary is not even fetched (initWasm() returns early; headless Node still initializes for benches). Opting in currently hangs the main thread on a full re-evaluation cycle — a non-deterministic bug in the Rust PersistentEvaluator. Everything you see in the app runs on the JavaScript evaluator, which after the Phase 1/8 work is fast enough on its own (sub-2 ms evaluation at 1000 notes). The WASM path passes in Node; the hang is browser-only.
Do not enable it. The infrastructure is here for when the hang is fixed.
To rebuild the artifacts (they are committed to src/wasm/, which is what the app and the Vercel deploy actually use):
npm run wasm:build # wasm-pack build + sync into src/wasm/If you built the crate by hand, sync with npm run wasm:sync and commit the updated src/wasm/rmt_core* files.
- Transport (top-left)
- Play/Pause, Stop, volume slider (0–1, persisted)
- Shift-click or long-press Play to toggle endless loop playback
- View (top-right)
- Reset View centers on the BaseNote (disabled while tracking is on)
- Playhead tracking keeps the playhead at the horizontal center — this locks horizontal panning for as long as it is on
- Gear opens the Settings panel (five tabs). It is the only way in; there is no menu entry.
- + opens the main menu: Undo, Redo, Reorder Module, Save Module, Load Module (from file / reset to default), plus Documentation, Donate, License links
- Editing
- Click to select a note, measure, or the BaseNote; click again on the same spot to cycle through an overlapping stack
- Drag a note body to move it; drag the right tab to resize
- Click the ▲/▼ arrows on a note's left edge to transpose by the configured interval (default: an octave)
- Drag measure triangles at the bottom to move a measure; dependents preview live
- Shift-drag (or long-press-drag on touch) on empty background to marquee-select; shift-click (or long-press) a note to toggle it in or out of the group
- The padlock (bottom-right) freezes all note interaction
- Scale
- The dot at the bottom-left unfolds X (time) and Y (pitch) density sliders. Both are mirrored by Settings → Scale, whose editable limits are the sliders' rails. Scale now persists across reloads.
- Import/Export
- Load a module from a file, or drag one from the module library onto a note, a measure bar, or the BaseNote
- Save Module downloads
module.json(reindexed on export). Save UI / Load UI export the library layout, not a composition.
- Keyboard — the only global shortcuts are undo and redo:
- Undo: Ctrl/Cmd+Z
- Redo: Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z
- Escape is scoped: it closes the Settings panel, clears the group selection, cancels a confirmation, or closes the library search — but only when focus is already inside that surface. There is no global Escape, no Space-to-play, and no Delete shortcut.
The second bar under the top bar. 79 shipped modules in six sections:
| Section | Count | Contents |
|---|---|---|
| Intervals | 46 | 3-limit, 5-limit, 7-limit, higher-limit (11–23) and commas |
| Chords | 11 | Major, Minor, Dominant 7th, Harmonic 7th, Minor 7th, Major 7th, Diminished, Augmented, Sus4, Base-3 chord, Base-5 chord |
| Progressions | 8 | V7–I, ii–V–I, I–IV–V–I, I–vi–IV–V, plus authentic / plagal / deceptive / half cadences |
| Melodies | 7 | Ode to Joy, Twinkle Twinkle, Frère Jacques, Amazing Grace, Greensleeves, Bach Minuet in G, Scarborough Fair |
| Scale Systems | 6 | 12-TET, 19-TET, 31-TET, Bohlen–Pierce, Tesla, Mixed-Base |
| Custom | 1 | canon base — and where your own modules land |
Every module is relational. A chord is a root plus ratios of that root; a progression chains each root off the previous one, so only the first root ever touches base. Drop a module onto a note and the whole structure re-roots onto that note with its tree intact; transpose the first root and the entire progression moves.
- Drag a module from the bar onto a note, a measure bar, or the BaseNote to import it. Dropping on empty background or on a silence is rejected.
- Drop Mode Toggle (
Drop at: Start | End) — Start anchors imported notes to the target's start time (chords); End appends+ [target].dso they land after it (sequences/scales). Ignored when dropping on the BaseNote. - The bar has a search magnifier (matches name, ratio, family, cents, tags, file path), its own Undo/Redo buttons, collapsible sections, drag-to-reorder, and a resize pull-tab.
- Save UI / Load UI / Add Category / Reload Defaults manage the layout, which autosaves to
localStorageunderui-state.
In the app — the intended path: select several notes (marquee or shift-click), then hit Copy to Modules in the group widget. The selection is exported as a self-contained module and lands in the Custom section, rooted at its earliest note, with the dependency tree preserved. It persists across reloads and can be dragged back onto the workspace like any other module. You can also click the dashed + placeholder at the end of any section to upload a .json file.
In the repo — the library is driven by a single top-level manifest, public/modules/library.json, shaped { "version": 2, "sections": [...] }:
- Add your module JSON under
public/modules/<section>/. - Add an item to the matching section's
itemsarray inlibrary.json.fileis required;name,ratio,cents,familyandtagsdrive the procedurally-drawn tile (family sets the hue; an/dratio renders as a stacked fraction with a cents caption) and the search index. npm testto validate it, then reload. Existing users get the new module without pressing Reload Defaults — a stored layout is reconciled against the manifest on every load.
The per-directory
public/modules/<category>/index.jsonfiles still exist, but they are the legacy fallback, used only whenlibrary.jsonis missing or not v2 — and that path only knows about four categories. Do not add modules there.
The default composition (what you see on first load) lives at public/modules/defaultModule.json and is not part of the library.
The DSL is a concise, mathematical notation for defining note relationships. Both DSL and legacy JavaScript syntax are auto-detected; the note widget always displays DSL, decompiling the compiled bytecode if the note was authored in legacy form.
| Syntax | Description |
|---|---|
base.f |
BaseNote frequency |
base.t |
BaseNote start time |
[1].f |
Note 1 frequency |
[5].d |
Note 5 duration |
[0].f |
Same as base.f — note id 0 is the base note |
| Short | Full Name | Also accepted |
|---|---|---|
f |
frequency | freq, frequency |
t |
startTime | s, start, startTime |
d |
duration | dur, duration |
tempo |
tempo | — |
bpm |
beatsPerMeasure | beatsPerMeasure |
ml |
measureLength | measureLength |
tempo, bpm and ml fall back to the base note when the referenced note doesn't define them. t, d and f do not inherit.
(3/2) # Fraction literal — the parentheses are part of the literal
440 # Integer
0.5 # Decimal (rationalized at compile time — prefer fractions for exactness)
base.f * (3/2) # Multiplication
[1].t + [1].d # Addition
2^(1/12) # Power (right-associative, binds tighter than * and /)
-[1].f # Negation
# this is a comment # '#' to end of line; comments are dropped on save
Precedence, loosest to tightest: + - → * / → unary - → ^ → atoms.
Exactly three. The argument must be a bare note reference ([N] or base), not an expression.
| Function | Description | Example |
|---|---|---|
beat(note) |
Duration of one beat = 60 / tempo(note) |
beat(base) |
tempo(note) |
Tempo of that note | tempo([1]) |
measure(note) |
Measure length of that note | measure(base) |
tempo() and measure() are input sugar: they compile to the same bytecode as .tempo / .ml and are written back as the property form on save. beat() is the only helper the decompiler reconstructs.
# Intervals (frequency relationships)
base.f * (3/2) # Perfect fifth (just intonation)
base.f * (5/4) # Major third (just intonation)
base.f * 2^(7/12) # Perfect fifth (12-TET)
# Timing (sequential notes)
[1].t + [1].d # Start after note 1 ends
base.t # Start with the base note
[1].t + measure([1]) # One measure after note 1
# Duration
beat(base) # One beat
beat(base) * 2 # Two beats
beat(base) * (1/2) # An eighth note
beat(base) * base.bpm # A full measure
Legacy JavaScript syntax (deprecated)
The legacy syntax uses method chaining on Fraction objects:
// Perfect fifth
module.baseNote.getVariable('frequency').mul(new Fraction(3, 2))
// Note 1 frequency
module.getNoteById(1).getVariable('frequency')
// One beat duration
new Fraction(60).div(module.findTempo(module.baseNote))Legacy syntax is auto-detected and still compiles, but the widget will show it as DSL and re-save it as DSL.
RMT is built around exact ratios, but power expressions let it express equal-temperament and other non-just systems in the same language.
Load these from the module library under Scale Systems:
- 12-TET — 12 equal divisions of the octave (13 notes: 12 steps + the start)
- 19-TET — 19 equal divisions of the octave
- 31-TET — 31 equal divisions of the octave
- Bohlen–Pierce — 13 equal divisions of the tritave (3:1), not the octave
- Tesla — an 81-note base-3 odd-harmonic scale: the odd harmonics 9, 11, 13, … 169 taken over 9. Not an EDO, not octave-repeating; spans ≈4.23 octaves.
- Mixed-Base — an experimental scale mixing 2-, 3- and 5-based steps that returns to
base.f
All six are chained: each note's frequency is the previous note's frequency times a step, so only note 1 touches base. Lift one note and every later note follows.
Notes whose frequency involves an irrational power display an ≈ prefix in the note widget and are cross-hatched on the canvas — a crosshatch when the note is directly irrational, a single diagonal when it merely depends on one. 4^(1/2) is not corrupted: it resolves to the rational 2.
| System | DSL Expression |
|---|---|
| 12-TET | [prev].f * 2^(1/12) |
| 19-TET | [prev].f * 2^(1/19) |
| 31-TET | [prev].f * 2^(1/31) |
| BP-13 | [prev].f * 3^(1/13) |
Example — chain notes up the 12-TET scale:
[1].f * 2^(1/12) # Note 2: one semitone above note 1
[2].f * 2^(1/12) # Note 3: one semitone above note 2
src/dsl/— DSL lexer, parser, compiler, decompiler, and DSL-native simplifiersrc/binary-note.js— binary expression format and bytecode opcodessrc/binary-evaluator.js— stack VM with Fraction pooling + incremental (Kahn) evaluatorsrc/dependency-graph.js— forward/inverse dependency indexes, per property, plus corruption flagssrc/expression-compiler.js— DSL/legacy routing, LRU compile cache, legacy compiler + decompilersrc/utils/simplify.js— simplification with a value + corruption-flag equivalence guard
src/main.js— ES module entry pointsrc/player.js— orchestrates workspace, audio, history, selection, and all top-bar wiringsrc/module.js— module data model, evaluation, save/load, reindexsrc/note.js— note modelsrc/store/— history, app state, event bus consumerssrc/modals/— note-variables widget, group widget, evaluate/liberatesrc/menu/— module library bar and procedural icon factorysrc/settings/— settings schema, store, and panelsrc/theme/— theme presets and the theme manager
src/renderer/webgl2/renderer.js— GL programs, instancing, text, overlays, pickingsrc/renderer/webgl2/workspace.js— interaction state machine (drag, marquee, gestures)src/renderer/webgl2/camera-controller.js— pan/zoom/pinch and the world-screen basissrc/player/audio-engine.js— streaming scheduler, play/pause/stop/loopsrc/player/audio-graph.js— buses, reverb send/return, limiter, master gainsrc/player/reverb.js— algorithmic impulse-response generatorsrc/instruments/— synth + multisample instruments
rust/— thermt-coreRust cratesrc/wasm/— committed build artifacts + the evaluator adapter
public/modules/library.json— the v2 library manifestpublic/modules/— the 79 shipped modules, plusdefaultModule.jsonpublic/samples/— the multisampled piano and violin (CC0)scripts/— module generators, the module validator (npm test), the sample builder, and the Playwright perf/visual harnesses
- WebGL2 is required. If a WebGL2 context cannot be created, the workspace will not initialize.
- WASM support is not required — the app runs entirely on the JS evaluator.
- Documentation: https://docs.rmt.world/
- Original article: https://cybercyril.com
Released under the MIT License © 2026 Cyril Monkewitz.
Bundled media assets and dependencies carry their own licenses (fraction.js — MIT; VSCO-2 Community Edition samples — CC0; Roboto Mono — Apache-2.0). See THIRD_PARTY_NOTICES.md for the full list.

{ "version": 2, "sections": [ { "id": "intervals", "label": "Intervals", "items": [ { "file": "intervals/3-2.json", "name": "Perfect 5th", "ratio": "3/2", "cents": 701.955, "family": "3-limit", "tags": ["P5", "perfect", "fifth"] } ]} ] }