# Host integration

## Worklet deployment

`new OPM({ workletUrl?: string | URL })` accepts an optional module path. Relative paths resolve against the page URL; a `URL` is copied when constructed. The default remains `new URL('../worklet/processor.js', import.meta.url)`. A custom module must be same-origin HTTP(S), with no credentials or fragment. HTTPS and secure loopback HTTP are supported; insecure contexts are rejected before audio resources are allocated. `start()` rejects and emits an `error` event when initialization fails, including normal CSP, network and MIME failures. Validation does not bypass browser policy.

Deploy the processor **and its relative imports**, not only one JavaScript file. Keep their installed layout; serve JavaScript as JavaScript and asset misses as 404, not SPA HTML. See the [installed-package Vite example (checkout-only)](https://github.com/JS-PACKAGE/OPM.js/blob/v1.10/examples/vite/README.md) for a non-root deployment with `script-src 'self'`, `worker-src 'self'` and no blob/eval allowances.

```ts
import { OPM } from 'opm.js';

const engine = new OPM({ workletUrl: '/assets/opm/worklet/processor.js' });
// Invoke initialization/resume from a trusted user gesture.
await engine.start();
```

## Worker WAV output and bounded sinks

`renderSequenceInWorker(events, { sink, format?, onProgress?, signal?, workerUrl?, ...chunkOptions })`
renders long scores in a static module Worker. Chunk options are the core
`renderSequenceChunks` options, including `sampleRate`, `chunkFrames` (1–65536,
default4096), `maxFrames`, voice registry, mix/tuning/stealing and `quality`.
`format` is `pcm16` (default), `pcm24`, or IEEE `float32`. Output is stereo RIFF32
WAV; oversized RIFF files reject before Worker allocation. Score, options and
patches are validated and detached first; there is no audio device/context.

The default module is `new URL('../worker/render.js', import.meta.url)`.
Deploy `dist/worker/render.js` **and all its relative imports**, maps and
declarations from the matching distribution. An explicit `workerUrl` resolves
against the page and must be same-origin HTTPS or secure loopback HTTP, with
no credentials/fragment. The default must also remain same-origin.
Use `worker-src 'self'`; no blob module, eval, dynamic executable source or
cross-origin loading is used. CSP, MIME and network failures reject normally.
Importing the browser API on Node/SSR is safe, but calling this helper requires
a browser with module Workers.

A sink is an own-data object with `write(Uint8Array): void | Promise<void>`,
optional `close()` and optional `abort(reason)`. The Worker transfers one byte
chunk and waits until the sink write fulfills before advancing DSP. The sink owns
the byte buffer and may transfer/detach it (for example, to a storage Worker);
byte accounting captures its native size before invoking `write`. Neither thread
accumulates a complete PCM buffer or WAV. The sink must actually drain to bounded
storage: retaining every chunk defeats the memory bound.
`onProgress({ frames, totalFrames, bytesWritten, errors })` runs after each
successful write, including the initial header (`frames:0`); frame counts are
cumulative. The result contains `capacity`, `format`, `bytesWritten` and
`diagnostics: { errors, processedEvents, renderedFrames }`. Success means
all bytes were written and `close()` fulfilled, not that a host file is audible.

Abort, sink/progress exceptions and Worker failures terminate the Worker,
remove listeners and reject. `abort(reason)` is invoked once, but its promise
and an outstanding `write`/`close` are **not awaited**: a hung sink cannot hold
cancellation hostage. Their later rejections are consumed. A sink must
invalidate in-flight writes and discard/roll back incomplete output; cancellation
cannot retract bytes already persisted. `abort` must not finalize an incomplete
file. Use host-side cleanup when abort itself fails.

For an actual file export, request File System Access from a trusted gesture
where it is supported. Feature-detect it; if unavailable, report that long-file
export is unsupported rather than aggregating a long WAV into a Blob:

```js
import { renderSequenceInWorker } from 'opm.js';

// Run this handler directly from a user gesture.
async function exportScore(events, signal) {
  signal?.throwIfAborted();
  if (typeof window.showSaveFilePicker !== 'function') {
    throw new Error('Streaming file export requires File System Access');
  }
  let file, settled = false;
  const rollback = async reason => {
    if (!file || settled) return;
    settled = true;
    await file.abort(reason);
  };
  try {
    const handle = await window.showSaveFilePicker({
      suggestedName: 'sequence.wav',
      types: [{ description: 'WAV audio', accept: { 'audio/wav': ['.wav'] } }],
    });
    signal?.throwIfAborted();
    file = await handle.createWritable();
    signal?.throwIfAborted();
    return await renderSequenceInWorker(events, {
      sampleRate:48000, chunkFrames:4096, format:'pcm24', signal,
      workerUrl:'/audio/opm/worker/render.js',
      sink: {
        write(bytes) { return file.write(bytes); },
        async close() { await file.close(); settled = true; },
        abort: rollback,
      },
    });
  } catch (error) {
    await rollback(error).catch(cleanup => console.error('File rollback failed', cleanup));
    throw error;
  }
}
```

The checkout demo's memory-only download is intentionally a **small export**:
preflight a hard frame/byte budget, retain chunks only within that budget, and
revoke its old Blob URL on replacement/disposal. It is not a fallback for a long
file sink. Hosts also own cumulative work, concurrency and export-frequency limits.

Node and browser hosts may use `createWavEncoder({ sampleRate, channels:1|2,
format?, totalFrames })` from `opm.js/core` directly. Write `header()` exactly
once, then each `encode({ left, right? })` result to a backpressured sink before
consuming the next borrowed PCM chunk; finally write `finalize()` (a RIFF
alignment byte or empty array). Each call accepts1–65536 frames. The declared
frame count is exact: overrun/underrun reject, and invalid chunks never advance
`framesEncoded`. No audio/file bytes are retained by the encoder. Its getters
are `totalFrames`, `framesEncoded`, `byteLength` and `finished`. Zero-frame WAVs
are supported by this streaming API. The full-buffer `encodeWav({ left,
right?, sampleRate, format? })` keeps its original1–4,000,000-frame budget.
All channels must be native Float32Arrays of finite samples in−1–1; encoding
rejects rather than clipping. PCM24 uses signed little-endian endpoints; float
WAV uses format3 with a `fact` sample-count chunk.

## Events, acknowledgements and ownership

`engine.subscribe(listener)` returns an idempotent unsubscribe function. Each subscription is independent, even for the same listener; it coexists with `onEvent` and scheduler subscriptions. Exceptions and attempts to mutate frozen event records cannot disrupt other listeners or cleanup. Removing a listener during dispatch skips its remaining delivery; newly added listeners begin with the next event. Ordinary `close()` preserves subscriptions for a later `start()`.

Commands still synchronously return numeric IDs. `waitForCommand(commandId, { timeout?, signal? })` waits only for the correlated **admission acknowledgement**, including a reply received before the method is called. The timeout is milliseconds: default **5000**, integer range **1..60000**. At most **64 pending waits** and **128 command receipts** are retained. Receipts without live waiters are evicted oldest-first; an unknown/expired ID rejects rather than waiting forever. Multiple waits for one ID are independent. Abort/timeout removes only that wait, not the admitted command or another waiter. A late acknowledgement can still be retrieved while its receipt is retained. Signals must be genuine AbortSignals: intrinsic state/reason and listener operations bypass shadow accessors and reject duck-typed objects.

A rejected acknowledgement throws `CommandRejectedError` with its frozen `event` (`reason`, note ID and command ID). Pending waits reject on reset, close, interruption/suspension or processor failure. A command-triggered reset carries its initiating `commandId`; an ordered panic reset rejects earlier outstanding commands, but preserves that panic's own acknowledgement and commands posted after it, even after receipt eviction. Uncommanded interruption resets invalidate every outstanding receipt. A settled acknowledgement is historical admission evidence and is not revoked by a later reset. **Accepted does not prove scheduled execution, gate release, release-tail completion or audible output.** Use note lifecycle events and diagnostics for those distinct observations.

```ts
const id = engine.playNote({ note: 60 });
const stop = engine.stop(id, { at: engine.context!.currentTime + 0.1 });
const acknowledgement = await engine.waitForCommand(stop, { timeout: 2000 });
// acknowledgement.state === 'accepted': future stop was admitted, not completed.
```

`close()` disconnects OPM's node, closes its port and removes its context listener; it is restartable. `dispose()` is terminal and idempotent: it also removes subscriptions/`onEvent`, rejects outstanding waits and prevents restart/new subscriptions. Both close an owned AudioContext, but **never close or suspend a borrowed context**. The host owns its additional routing nodes and any context it supplied.

## Cross-feature contracts

| Identity / observation | Meaning | Not equivalent to |
| --- | --- | --- |
| Score note ID | Local musical identity used by note, control and stop events | A live OPM note ID; score IDs are remapped on admission |
| OPM note ID | One live note's owned lifecycle | A physical key ID or a command ID |
| Performance key ID | One physical key press, distinct even at equal pitch | Its current sounding gate; mono selection may reuse or replace a gate |
| Command ID / `accepted` | Correlated command admission | Scheduled execution, audible onset or completed release |
| Note `started` / `released` / `ended` | Audio-frame lifecycle transitions | Device-output or microphone-observed timing; `released` may retain a tail |
| Arrangement committed beat | The scheduled musical change boundary | Immediate application; already admitted notes retain their times |
| `getDiagnostics()` | Voice/queue counts, errors and rejected notes | CPU utilization, GC pauses, underrun counters or audible continuity |

Persist authored music, not an audio session. [Score projects](./score-projects.md) store finite beat events; Arrangement projects store looping layers and named sections. Both retain validated named patches, tempo/meter and synthesis settings. They do not retain current DSP phases/envelopes, live key/pedal ownership, pending section commands, host effects/routing or MIDI permissions/controller mappings. Hosts save those additional application choices separately and explicitly.

Parsing or loading a file never grants permission or starts audio. Load the validated named voices, construct the intended engine with its immutable quality/voice budget, connect the host graph and start from a trusted gesture. Dispose the old helper and its owned engine when replacing a session; never close a context borrowed from another component. Treat new project definitions as musical starts, not seamless continuation.

Live MIDI and MIDI files are different contracts. The live adapter applies channel/key policies to owned gates; an SMF conversion must make its expressive-controller policy explicit and expose every reported omission or approximation. A valid MIDI or project round trip does not prove hardware fidelity or lossless conversion of unsupported musical semantics. See [MIDI files](./midi-files.md).

For reproducible sound retain the package version, normalized patch identity, sample rate, quality, voice budget, tuning, mix gain and stealing policy. Retain host/output gains and routing separately: downstream attenuation cannot undo the engine's saturation. Numerical output and measured host capacity remain separate from [physical recovery](./mobile-acceptance.md) and [human listening acceptance](./voice-quality.md).


## Audio/output clock mapping and component cleanup

`getOutputTimestamp()` pairs an AudioContext `contextTime` (seconds at device output) with the corresponding `performanceTime` (milliseconds on the performance clock). It is not a pair of independent current-time samples. Map an audio event at `audioTime` to an estimated output time with:

```ts
const stamp = context.getOutputTimestamp();
const estimatedOutputMs = stamp.performanceTime + (audioTime - stamp.contextTime) * 1000;
```

Do not add `baseLatency` or `outputLatency` again: the timestamp already describes output. Timestamp support/precision varies, the initial pair may be zero, and the result is an estimate—not microphone-observed acoustic onset. Re-sample while running after resume/device changes. If unavailable, display audio-clock timing only rather than inventing output-clock precision.

The following host can be mounted beside a button/status element. `unmount()` removes DOM/OPM listeners, aborts outstanding waits, disposes OPM, disconnects host routing and closes the host-owned context. The asynchronous gesture handler checks teardown after every asynchronous startup boundary.

```ts
import { OPM } from 'opm.js';

const button = document.querySelector<HTMLButtonElement>('#play')!;
const status = document.querySelector<HTMLElement>('#status')!;
const context = new AudioContext();
const output = context.createGain();
output.gain.value = 0.08;
output.connect(context.destination);
const engine = new OPM({ context, destination: output,
  workletUrl: '/assets/opm/worklet/processor.js' });
const lifetime = new AbortController();
let mounted = true;
let note: number | undefined;
const unsubscribe = engine.subscribe(event => {
  if (event.type === 'error') status.textContent = event.error.message;
  if (event.type === 'reset') note = undefined;
  if (event.type === 'note' && event.id === note) {
    status.textContent = event.state;
    if (['ended', 'stolen', 'cancelled', 'rejected'].includes(event.state)) note = undefined;
    if (event.state === 'started' && typeof context.getOutputTimestamp === 'function') {
      const stamp = context.getOutputTimestamp();
      if (stamp.performanceTime > 0 && Number.isFinite(stamp.contextTime) && Number.isFinite(stamp.performanceTime)) {
        const outputMs = stamp.performanceTime + (event.time - stamp.contextTime) * 1000;
        status.title = `Estimated output onset: ${outputMs.toFixed(1)} ms`;
      }
    }
  }
});
let busy = false;
const onClick = async () => {
  if (!mounted || busy) return;
  busy = true;
  try {
    await context.resume(); // Called synchronously in the trusted gesture.
    if (!mounted) return;
    await engine.start();
    if (!mounted) return;
    if (note !== undefined) {
      await engine.waitForCommand(engine.stop(note), { signal: lifetime.signal });
      if (!mounted) return;
    }
    // A desired performance-clock target converted back to audio seconds.
    const desiredOutputMs = performance.now() + 150;
    let at = context.currentTime + 0.05;
    if (typeof context.getOutputTimestamp === 'function') {
      const stamp = context.getOutputTimestamp();
      if (stamp.performanceTime > 0 && Number.isFinite(stamp.contextTime) && Number.isFinite(stamp.performanceTime)) {
        at = Math.max(at, stamp.contextTime + (desiredOutputMs - stamp.performanceTime) / 1000);
      }
    }
    note = engine.playNote({ note: 60, at, duration: 1 });
  } catch (error) {
    if (mounted) status.textContent = error instanceof Error ? error.message : String(error);
  } finally { busy = false; }
};
button.addEventListener('click', onClick);

let teardown: Promise<void> | undefined;
function unmount(): Promise<void> {
  if (teardown) return teardown;
  mounted = false;
  button.removeEventListener('click', onClick);
  lifetime.abort();
  unsubscribe();
  teardown = (async () => {
    try { await engine.dispose(); }
    finally {
      output.disconnect();
      if (context.state !== 'closed') await context.close();
    }
  })();
  return teardown;
}
```

For a shared context owned elsewhere, omit its final `context.close()`; disconnect only the routing nodes this component owns. `streamSequence` and `createLookaheadScheduler` have their own `dispose()` methods: dispose each scheduler before its OPM instance so admission timers and live-note ownership are released.

## Other bundlers and SSR hosts

Webpack, Rollup, Parcel and static hosts use the same explicit static-asset contract without relying on unverified worklet-plugin APIs: deploy the installed package's **entire `dist/` tree plus the package-root `LICENSE`** at a same-origin URL, preserving relative paths. This is a packaging recipe, not a claim that those other bundlers were exercised. Only the existing Vite production smoke provides bundler-specific runtime verification.

The package ships a dependency-free helper that does this safely: Run it from a project with OPM.js installed; `--no-install` prevents an accidental registry fetch.

```sh
npx --no-install opm-assets copy public/audio/opm-1.10.0     # atomic copy into a NEW directory
npx --no-install opm-assets check https://example.test/audio/opm-1.10.0/   # byte-for-byte check of what is served
```

`copy` (also `import { copyAssets } from 'opm.js/tools/assets.js'`, Node only) inspects the installed release first: only `.js`, `.js.map` and `.d.ts` files under `dist/`, no symlinks, every `.js` with its map and declaration, the entry modules `api/index.js`, `core/index.js`, `worklet/processor.js` and `worker/render.js`, and a bounded size (4,096 files, 16 MiB). It stages a temporary directory beside the destination, copies and re-hashes every file, writes `opm-assets.json` (SHA-256 and size per file, plus `LICENSE`) and renames the staging directory into place. It **never overwrites**: a destination that already holds exactly this release is reported as `reused: true`; anything else is an error and left untouched. Use a fresh release-specific directory (or an atomic deployment switch) so removed old assets cannot mix with a new tree.

`check` (or `checkDeployment(url)`) downloads each listed file from an HTTPS or loopback-HTTP base URL and compares status, a JavaScript MIME type, `X-Content-Type-Options: nosniff` (disable with `requireNosniff: false`) and the SHA-256 against the installed release. Redirects are errors, so an SPA fallback that returns HTML with status 200 fails on MIME or bytes. It cannot see your page's Content-Security-Policy and does not start an AudioWorklet or Worker: run the browser smoke or the [Vite example (checkout-only)](https://github.com/JS-PACKAGE/OPM.js/blob/v1.10/examples/vite/README.md) for that.

Configure `new OPM({ workletUrl: '/audio/opm-1.10.0/worklet/processor.js' })` and, for Worker export, `workerUrl: '/audio/opm-1.10.0/worker/render.js'`. The worklet's own imports must remain deployed beside it and use valid JavaScript MIME types; no blob URL or relaxed CSP is needed. The host can bundle the browser-facing API normally or import `/audio/opm-1.10.0/api/index.js` externally.

An SSR module may import types safely, but instantiate/resume OPM only on the client, from a trusted user gesture. Keep module initialization free of `window`, `document` and `AudioContext` access on the server; dynamically import the runtime inside client-only code when required by the host. Dispose it during component teardown using the ownership recipe above.

## Part-scoped performance

`createPerformance()` supplies playing policies, not a MIDI driver. It requires
a started OPM and never owns its AudioContext. Keep returned **physical key IDs**
until their matching key-up; repeated equal-pitch keys have separate identities.

```js
import { createPerformance } from 'opm.js';

// opm is already started from the host's trusted input gesture.
const performance = createPerformance(opm, {
  parts: 16, maxKeys: 128, maxKeysPerPart: 16,
  onError: error => console.error(error),
});
performance.configurePart(0, {
  voice: 'brass', mode: 'mono', legato: true, priority: 'high', glide: 0.02,
  pan: 0, expression: 0.8,
});
const key = performance.noteOn(0, 60, { velocity: 0.7 }); // host key-down
performance.sustain(0, true);                           // pedal-down
performance.noteOff(0, key);                            // physical key-up
// Later, in the pedal-up input handler:
// performance.sustain(0, false);
// performance.updatePart(0, { pan: -0.5, expression: 0.6, glide: 0.03 });
// performance.allNotesOff(0); // only this part; no global panic
// performance.dispose();     // release all helper-owned gates on unmount
```

Parts are 0-based, with 1–16 configured parts (default 16), at most 128 keys and
128 tracked gates system-wide, including pending releases. Per-part limits share
that system budget; overflow rejects without evicting unrelated parts. A part
may use `mode: 'poly'` or `'mono'`, `legato`, and `priority: 'last' | 'high' | 'low'`.
Physically held keys take precedence over pedal-only keys; equal pitches choose
the newest identity. Mono legato preserves the original onset's envelope,
velocity and key/rate scaling. Its pitch offset is anchored to that onset:
within ±48 semitones it reuses the gate, farther moves retrigger at the actual
pitch instead of clamping. Non-legato switching retriggers the patch.

`getPart(part)` returns a detached frozen snapshot. Stealing prunes the affected
gate without later automatic readmission; reset clears state. Interruption
releases helper-owned notes even when direct OPM notes use preserve policy.
Dispose performance/Transport helpers before disposing their shared engine.
Their cleanup does not release unrelated host or Transport notes.

Release 1.8 adds `updateKey`, `updatePartNotes`, per-part `voiceLimit`/`voicePriority` and an optional Web MIDI adapter; see [expressive performance and MIDI](./midi-performance.md). Looping layers and boundary-quantized section switching are covered in [adaptive music](./adaptive-music.md), and Worker startup and phase diagnostics in [Worker diagnostics](./worker-diagnostics.md).
