OPM.js documentationOnline demos

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

Adaptive music, tempo curves and the seek contract #

This guide covers createArrangement (looping layers and sections that switch on musical boundaries), tempo curves and grid helpers shared by createTransport, and the exact meaning of pause, seek and resume. The v1.10 example 09 (checkout-only) demonstrates the scheduler and portable arrangement save/load. The portable APIs ship in package 1.10.0; build and serve examples/adaptive.html from the matching source checkout to use the example.

Arrangement #

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

const opm = new OPM({ maxVoices: 16 });
const arrangement = createArrangement(opm, {
  bpm: 96,
  initialSection: 'explore',
  layers: [
    { name: 'pad', length: 16, voicePriority: 20,
      events: [{ type: 'note', id: 1, beat: 0, duration: 16, note: 48, voice: 'brass' }] },
    { name: 'arp', length: 8, events: [
      { type: 'note', id: 2, beat: 0, duration: 0.5, note: 60 },
      { type: 'note', id: 3, beat: 1, duration: 0.5, note: 67 },
    ] },
    { name: 'lead', length: 8, voicePriority: 100, events: [
      { type: 'note', id: 4, beat: 0, duration: 2, note: 72 },
    ] },
  ],
  sections: [
    { name: 'explore', layers: ['pad', 'arp'] },
    { name: 'combat', layers: ['pad', 'lead'] },
  ],
});
await arrangement.start();               // call from a user gesture
arrangement.switchSection('combat', { fade: 0.8 }); // crossfade; returns committed beat
arrangement.setLayer('arp', true, { quantize: 'beat', fade: 0.4 });
arrangement.setLayerGain('pad', 0.6, { quantize: 'beat', fade: 1 });

In a plain browser, import the served ./opm/api/index.js URL instead of the bare package name and invoke the startup/switch calls from a button handler after deploying the complete tree as in the quick start. This runnable recipe uses the default brass voice; register your own pad/lead patches with loadVoice before using their names. On host teardown, call arrangement.dispose() and await opm.dispose().

Rules that make switches musical:

snapshot reports state, position, bar/beat, the current and pending sections and layers, the tempo map and priorityDrops. setTempo(bpm) and setTempoMap(map) replace the tempo from the current beat on; notes that were already admitted keep the audio times they were admitted with (at most one lookahead window).

Save and reload an interactive arrangement #

Keep a definition separate from the live scheduler. parseArrangementProject / serializeArrangementProject, available since v1.10, store a version 1 ArrangementProject containing named complete voices, normalized synthesis settings, tempo/meter, layers (length, events, gain and priority), named sections, and initialSection. This is distinct from the unchanged ScoreProject v1. See the self-contained creation and live replay recipe.

Portable parsing and live creation share a DOM-free validator; neither creates audio or fetches assets. After parsing, create new OPM({ ...project.settings }), clear its implicit brass registry with replaceVoiceBank([]), load each [name, voice] from project.voices, and pass { layers, sections, initialSection, tempoMap, timeSignature } to createArrangement. Only start() inside a user gesture plays it. Loading/replacing must dispose the old owned arrangement first; teardown the old owned synth without closing another component's borrowed context.

The local current adaptive page accepts arbitrary valid project names and rebuilds its section/layer/gain controls from them. Section switches become the saved initialSection; layer toggles edit that section's membership, and gain/tempo edits update the saved definition. Save definition → Load arrangement JSON → Start restores those requested targets and registered voices/settings, after which switching and fading still work. A pending boundary is saved as its requested target, not its transition timeline. A malformed or oversized file rejects visibly; uploads are bounded before reading. Loading and Reset demo stop previous owned playback and never auto-play. Reset demo discards the imported definition and applies the selected swing.

The adaptive page owns and closes its own context, with a separate 16% host monitoring gain. It preserves saved synthesis settings and reports unsupported context/sample-rate failures instead of replacing them. A component borrowing a host context must not close it.

Saving is not a snapshot of a live Arrangement. It excludes beat position, queued changes, running fades, active gates, oscillator/envelope/LFO/filter state and release tails. Replay begins at beat 0; no exact DSP-state restore is claimed. Budgets are 8 MiB, 128 named voices / 256 KiB normalized bank, 1–16 layers, at most 32 sections and 65,536 total events. Default tempo/meter/settings are the same as score projects, gain defaults to 1 and priority to 0; authored note-control gain remains reserved.

Tempo curves #

TempoPoint is { beat, bpm, curve?, endBpm? }. Without curve (or with 'step') the tempo is constant until the next point, exactly as in 1.7. With curve: 'linear' the BPM changes linearly per beat to the next point's bpm, or to endBpm if given (endBpm is only valid with 'linear'; the last point cannot be linear).

For a segment starting at beat b0 with bpm0 and slope r = (bpm1 − bpm0) / (b1 − b0), the time to beat b is

$$t(b) = \int_{b_0}^{b}\frac{60}{\mathrm{bpm}0 + r\,(x-b0)}\,dx = \frac{60}{r}\ln\left(1 + \frac{r\,(b-b_0)}{\mathrm{bpm}_0}\right)$$

(60·Δb/bpm0 when r = 0), and the inverse is Δb = bpm0 · expm1(r·t / 60) / r. beatsToSeconds and secondsToBeats use these closed forms with log1p and expm1, so ramps convert exactly across segment boundaries. setTempo on a running transport or arrangement truncates any ramp that is under way at the current beat (its endBpm becomes the interpolated BPM) and inserts a new constant tempo there.

Linear maps whose derived slope is not finite are rejected before admission, including subnormal beat intervals that would overflow the BPM-per-beat calculation.

Grid helpers #

Import these grid helpers from opm.js (or the deployed browser api/index.js), not opm.js/core. Beat/second conversion helpers are exported by both entry points.

What pause, seek and resume mean #

createTransport and createArrangement are musical restarts, not DSP checkpoints.

StatePreservedNot preserved
Musical position (beats), tempo map, loopyes
Transport seek()/loop wrap into a sustained notethe note restarts at the target beat, with pitch, expression, gain, pan, modulation, ratios, feedback, LFO, level and ADSR values reconstructed from the score's controls and remaining rampsoscillator phase, envelope stage and level, LFO phase, filter and feedback history, any release tail that was sounding
Arrangement pause() / resume()the beat, effective layer set and current layer gain envelopes; unfinished fades continue after resumeevery owned note is released on pause; on resume only notes whose onset lies at or after the resume beat start, so a long pad that was mid-note re-enters at its next onset
Notes admitted by the host outside the transport/arrangementuntouched

An exact snapshot of oscillator phases, envelope state and filters is deliberately not offered: it would couple the public API to private DSP state and make 1.x DSP changes breaking. If a game needs a continuous pad across a scene change, keep that layer in both sections (it is then never interrupted), or keep the pad in a separate engine whose lifetime you control. Transport start and every explicit reconstruction use a future audio anchor (startupLead seconds, default min(0.05, horizon / 2), finite 0..10). The musical cursor stays at the destination during this count-in; note durations and loop lengths are not stretched. Arrangement uses the same default lead and freezes its resume cursor during count-in. Lead is delivery headroom, not a guarantee: late/drop failures and missed windows remain errors.

Verified behavior #

test/arrangement.test.ts in the v1.10 checkout renders through the real AudioWorklet processor. It checks that a pad shared by two sections is admitted once across a bar switch, that a removed layer is released at the exact boundary beat and that preserveNotes lets it finish, that a high-priority melody survives a one-voice engine without failing the arrangement, that a tempo change alters only unadmitted onsets, and that pause schedules nothing. test/transport.test.ts in that checkout checks the logarithmic integral and its inverse to 1e-9 beats. Arrangement-project tests additionally define immutable/canonical parse boundaries, malformed/budget rejection and identical real-worklet PCM after saved-definition replay with section switches and fades; these are automated invariants, not physical device or human listening acceptance.