OPM.js documentationOnline demos

Generated from Markdown source. Edit the source, not this HTML.

Replayable score projects #

Available since v1.9. A version 1 ScoreProject stores musical events, a tempo map, meter, named complete voice snapshots and synthesis settings in one self-contained JSON file. The pure APIs import from opm.js/core in Node without an AudioContext, DOM or Worker.

Create, save and load #

import { parseScoreProject, serializeScoreProject } from 'opm.js/core';
import { brass } from 'opm.js/voices/brass.js';

const project = parseScoreProject({
  version: 1,
  voices: { lead: brass },
  events: [
    { type: 'note', id: 1, voice: 'lead', note: 60, beat: 0, duration: 4 },
    { type: 'control', id: 1, beat: 2, controls: { expression: 0.6, ramp: 0.2 } },
  ],
  tempoMap: [{ beat: 0, bpm: 90, curve: 'linear' }, { beat: 4, bpm: 120 }],
  timeSignature: { numerator: 4, denominator: 4 },
  settings: { sampleRate: 44100, quality: 'standard', maxVoices: 8,
    mixGain: 0.35, stealing: 'release-first', tuning: { referenceHz: 440 } },
});
const json = serializeScoreProject(project);
const replay = parseScoreProject(json);

The parser accepts a JSON string or a plain own-data object. version, events and voices are required. Omitted tempo, meter and settings normalize to 120 BPM, 4/4, 44100 Hz, standard, eight voices, mixGain 1, oldest stealing and 440 Hz with zero tuning offsets. Canonical output contains all settings and the 128-entry tuning table. Voice names are sorted, controls use stable key order, and saved defaults are explicit. Event array order is retained for deterministic simultaneous-event ordering.

Every note uses a stored voice name. Omitted note voice means brass, which must also be stored. Inline patches are rejected inside projects; standalone compileBeatSequence accepts them. Voice definitions normalize through the existing voice-version validator, including supported legacy patch inputs; project files themselves must specify version 1. No external bank or URL is referenced.

Returned objects, arrays, voices, controls and settings are detached and frozen. Unknown fields, inherited fields, accessors, invalid versions, malformed meters/tempo maps, invalid controls/settings, duplicate note IDs and missing note/voice references throw. There is no prototype merge or invocation of JSON serialization hooks from caller data. Do not pass Proxies or executable objects as project data.

Budgets: 8 MiB total serialized project, 128 stored voices with a 256 KiB normalized bank budget, 65,536 events and 86,400 quarter-note beats. Compiled seconds must fit the existing 24-hour long-sequence horizon. Array holes/extra properties are rejected. These budgets do not enlarge worklet queues or full-buffer rendering limits.

Portable Arrangement projects #

Available since v1.10, ArrangementProject is a separate version 1 definition, not an extension of ScoreProject v1. Existing score files keep their schema and replay behavior. The APIs parseArrangementProject(source: string | object) and serializeArrangementProject(project) ship in package 1.10.0 and are exported from both opm.js and opm.js/core; core parsing needs no DOM or Web Audio declarations. Interactive save/load examples remain checkout-only.

import { parseArrangementProject, serializeArrangementProject } from 'opm.js/core';
import { brass } from 'opm.js/voices/brass.js';

const adaptive = parseArrangementProject({
  version: 1,
  voices: { pad: brass, lead: brass },
  settings: { sampleRate: 44100, maxVoices: 8, mixGain: 0.2 },
  tempoMap: [{ beat: 0, bpm: 96 }],
  timeSignature: { numerator: 3, denominator: 4 },
  layers: [
    { name: 'bed', length: 12, gain: 0.6, voicePriority: 20,
      events: [{ type: 'note', id: 1, beat: 0, duration: 12, note: 48, voice: 'pad' }] },
    { name: 'melody', length: 3, gain: 0.8, voicePriority: 100, events: [
      { type: 'note', id: 1, beat: 0, duration: 2, note: 72, voice: 'lead' },
      { type: 'control', id: 1, beat: 1, controls: { expression: 0.5, ramp: 0.2 } },
    ] },
  ],
  sections: [{ name: 'calm', layers: ['bed'] }, { name: 'battle', layers: ['bed', 'melody'] }],
  initialSection: 'calm',
});
const saved = serializeArrangementProject(adaptive);
const restored = parseArrangementProject(saved);

Required fields are version, voices, layers, sections and initialSection. Tempo, meter and synthesis settings use exactly the score-project defaults; each layer normalizes gain to 1 and voicePriority to 0. Named voices, control-property order and all defaults are canonical; layer, section, member and event array order is retained. Every note references a stored voice; note IDs are unique within each layer, so separate layers may reuse IDs. Notes must start inside the layer's loop; durations and owned controls may extend beyond its end. Authored control gain is reserved for the layer and rejects; use expression.

Both live createArrangement and portable parsing use the same pure definition validator. They reject duplicate layer/section names, unknown references/fields, sparse arrays, accessors, invalid controls and out-of-range values. Repeated section members normalize to one membership in first-occurrence order, preserving existing live semantics. The returned layers, sections, events, voices, tempo/meter and settings are detached and deeply frozen. Projects allow 1–16 layers, at most 32 sections, 65,536 events in total, loop lengths 1/1024–256 quarter notes, priorities 0–127 and gains 0–1. The required initial section must exist (it may have no active layers). The 8-MiB project / 128-voice / 256-KiB normalized-bank budgets and beat/compiled-second horizons above also apply; the corresponding exported constants are MAX_ARRANGEMENT_PROJECT_BYTES and MAX_ARRANGEMENT_PROJECT_VOICES. Hosts must check upload/download bytes before buffering.

Load into the live API #

import { OPM, createArrangement } from 'opm.js';

// Inside a browser click handler; dispose the previous owned arrangement/synth first.
const synth = new OPM({ ...restored.settings });
synth.replaceVoiceBank([]); // Remove implicit brass: a project may already store 128 names.
for (const [name, voice] of Object.entries(restored.voices)) synth.loadVoice(name, voice);
const music = createArrangement(synth, {
  layers: restored.layers, sections: restored.sections, initialSection: restored.initialSection,
  tempoMap: restored.tempoMap, timeSignature: restored.timeSignature,
  onError: error => console.error(error),
});
await music.start(); // Parsing/loading alone never starts audio.
music.switchSection('battle', { quantize: 'bar', fade: 0.8 });
music.setLayerGain('bed', 0.4, { quantize: 'beat', fade: 0.5 });
// When this host is finished:
music.dispose();
await synth.dispose();

Use the complete current built distribution for this example. A browser may choose a different actual sample rate, or reject a requested context/rate; surface that failure rather than silently substituting settings. On host teardown, dispose the old arrangement before replacing its owned synth; do not close a context borrowed from another component.

The saved object is an authored replay definition. It does not store the current musical cursor, scheduled changes, in-progress fades, note IDs, oscillator/envelope/LFO/filter history, or a DSP checkpoint. Loading starts the definition at beat 0 and its initialSection only after a user gesture. There is no serializer for live Arrangement objects; keep the authored definition separately and explicitly commit host edits into it.

In the current checkout, build and serve examples/adaptive.html: switch sections, toggle a layer, change a gain target or accelerate, then Save definition → Load arrangement JSON → Start. The page saves requested targets and the edited section definition (including edits whose musical boundary is still pending), not the pending transition itself. Loading disposes prior owned playback, rebuilds controls for arbitrary valid layer/section names, and stays stopped. Reset demo intentionally replaces the loaded definition and applies the selected swing without playing. Playback, subsequent switches and fades reuse the saved voices/settings; no code edits are needed.

The adaptive page owns its context and attenuates monitoring to 16% separately from the saved synthesis mixGain; saved settings are not weakened or silently rewritten for playback. Context/rate initialization failures remain visible.

Replay with Transport #

import { OPM, createTransport } from 'opm.js';

// Create/resume this context inside a user gesture in a browser.
const context = new AudioContext({ sampleRate: replay.settings.sampleRate });
const monitor = context.createGain();
monitor.gain.value = 0.16;
monitor.connect(context.destination);
await context.resume();
const opm = new OPM({ ...replay.settings, context, destination: monitor });
for (const [name, voice] of Object.entries(replay.voices)) opm.loadVoice(name, voice);
const transport = createTransport(opm, replay.events, {
  tempoMap: replay.tempoMap,
  timeSignature: replay.timeSignature,
  onError: error => console.error(error),
});
await transport.start();
// Stop the owned score, then release the synth and the owned context when done.
transport.dispose();
await opm.close();
monitor.disconnect();
await context.close();

Transport retains musical timing for restart/tempo edits. The actual browser sample rate may differ from the requested rate. Host monitoring attenuation is deliberately separate from saved synthesis mixGain.

Node: load → compile → chunk WAV #

import { readFile, open } from 'node:fs/promises';
import { parseScoreProject, compileBeatSequence, renderSequenceChunks,
  createWavEncoder } from 'opm.js/core';

const score = parseScoreProject(await readFile('piece.opm.json', 'utf8'));
const voices = new Map(Object.entries(score.voices));
const events = compileBeatSequence(score.events, { tempoMap: score.tempoMap, voices });
const chunks = renderSequenceChunks(events, {
  ...score.settings, voices, chunkFrames: 4096,
  maxFrames: score.settings.sampleRate * 120,
});
const wav = createWavEncoder({ sampleRate: score.settings.sampleRate,
  channels: 2, format: 'pcm16', totalFrames: chunks.capacity.frames });
const file = await open('piece.wav', 'w');
try {
  // FileHandle.writeFile completes each byte chunk before the next render step.
  await file.writeFile(wav.header());
  for (const chunk of chunks) await file.writeFile(wav.encode({ left: chunk.left, right: chunk.right }));
  await file.writeFile(wav.finalize());
} finally {
  chunks.cancel();
  await file.close();
}

compileBeatSequence(events, { tempoMap?, bpm?, voices? }) returns validated second-based SequenceEvent[], preserving named voice references. Supply the same registry to the render consumer. Its default tempo is 120 BPM; tempoMap takes precedence over bpm. A gate's duration is the integrated end time minus integrated onset time, including all tempo ramps and steps it crosses. Control ramp/glide durations remain seconds, not beats. IDs and stable input ordering are preserved. The existing full-buffer renderSequence and browser renderSequenceInWorker consume the same compiled events/settings; existing queue/render capacity limits still apply.

The opm.js/core and opm.js/midi-file declarations can be consumed by Node TypeScript projects without DOM or Web Audio globals. This boundary is checked separately from the browser API declarations.

Chunk arrays are borrowed; encode/write each chunk before advancing. A failed write can leave a partial file: use a temporary output and rename only after successful finalization if atomic replacement is required. Rendering errors and device output are different evidence: deterministic WAV tests do not certify physical playback or subjective listening quality.

Original composition showcase #

Open examples/showcase.html over localhost or HTTPS after building. Harbor at First Light is a D-major/B-minor 4/4 miniature with a glass lead, a middle countermelody and a slowing return. Lanterns on the Stair is an E-minor 3/4 miniature with a chromatic dominant and an open-ninth ending. Both melodies and arrangements are original to this demo, not copied songs.

Play/Stop uses the public Transport API and a low 16% host monitor gain. Render WAV streams PCM16 in a Worker, with a 90-second/32-MiB demo download budget. Save project writes canonical JSON; Load project validates before replacing the current score. Reload either saved composition, play it and render it again to exercise replay. Offline export preserves synthesis mixGain, not the extra host monitoring gain. Stop also cancels an active export. No physical iOS/Android or human listening outcome is asserted here.