WAX Native Developer Guide

Introduction

WaxNative is for creating apps capable of running inside WAX’s internal audio engine, synced to the host at native sample rate for lower latency and better performance. A WaxNative device is just an ordinary HTML page that builds its sound through the helper objects WAX supplies, instead of the browser’s own Web Audio API. The engine keeps running while a WaxNative page is loaded. Loading a normal Web Audio page disconnects it.

Page flow

1. Overview

Helper

Everything centers on the helper, wax-native.js that gives you a global WaxNative object. From there you build sound as a chain of nodes similar to the Web Audio API (wax.cycle for an oscillator, wax.svf for a filter, etc.). When the chain is ready, render it to the engine with wax.render(left, right), and the engine keeps playing it until you render changes.

Nodes

WaxNative devices are made up of small audio blocks very similar to the standard Web Audio API — oscillators, filters, gains, MIDI inputs, and so on. In code you create them with wax methods (wax.cycle, wax.svf, wax.mul, …) and plug the outputs of one into the inputs of the next until you reach wax.render.

Every supported method is listed in Reference (same names as WEB.md).


2. Get started

Load the helper

wax-native.js is always present in the plugin. Add the tag manually to support running outside the plugin (Chrome, Safari, etc.):

<script src="https://szfpro.github.io/CodeEditorHTML/wax-native.js"></script>

Create the instance

One wax instance per page. Run once on load:

wax = await WaxNative.create();

Render Node Chain

Build the chain in a dedicated function — for example renderDevice() — and commit with wax.render. Putting the chain in a dedicated function is good practice when params, presets, or other state need to update what the engine plays.

function renderDevice() {
  const tone = wax.mul(0.15, wax.cycle(440));
  wax.render(tone, tone);
}

UI and parameters

Store knob values in params and read them in renderDevice() instead of fixed numbers. Wire controls through setParam.

Sliders fire input very often while you drag. Calling renderDevice() on every event rebuilds the graph too fast and can stutter. Wait about 16 ms between commits (scheduleRender), then call flushRender() on change when the user releases the control so the final value is exact. Boot and one-shot updates can still call renderDevice() immediately. More under Sliders stutter. MIDI notes are in MIDI; host CC in Automation.

const params = { gain: 0.15, frequency: 440 };

let renderTimer = null;
function scheduleRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = setTimeout(function () {
    renderTimer = null;
    renderDevice();
  }, 16);
}
function flushRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = null;
  renderDevice();
}

function setParam(name, value) {
  params[name] = value;
  scheduleRender();
}

gainSlider.addEventListener("input", function () {
  setParam("gain", Number(gainSlider.value));
});
gainSlider.addEventListener("change", flushRender);

Starter page

Minimal full file: load helper, one script (create → renderDevice → slider wiring), then markup. Project / hub URLs: Troubleshooting. Host CC wiring is in the Automation starter; note-driven synths in the MIDI starter.

<script src="https://szfpro.github.io/CodeEditorHTML/wax-native.js"></script>
<script>
let wax = null;

async function init() {
  wax = await WaxNative.create();
  renderDevice();
}

function renderDevice() {
  const tone = wax.mul(params.gain, wax.cycle(params.frequency));
  wax.render(tone, tone);
}

const params = { gain: 0.15, frequency: 440 };

let renderTimer = null;
function scheduleRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = setTimeout(function () {
    renderTimer = null;
    renderDevice();
  }, 16);
}
function flushRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = null;
  renderDevice();
}

function setParam(name, value) {
  params[name] = value;
  scheduleRender();
}

document.addEventListener("DOMContentLoaded", function () {
  const gainSlider = document.getElementById("gain");
  const freqSlider = document.getElementById("frequency");
  gainSlider.addEventListener("input", function () {
    setParam("gain", Number(gainSlider.value));
  });
  gainSlider.addEventListener("change", flushRender);
  freqSlider.addEventListener("input", function () {
    setParam("frequency", Number(freqSlider.value));
  });
  freqSlider.addEventListener("change", flushRender);
  init();
});
</script>

<label>Gain
  <input id="gain" type="range" min="0" max="1" step="0.01" value="0.15" />
</label>
<label>Frequency (Hz)
  <input id="frequency" type="range" min="50" max="2000" step="1" value="440" />
</label>

Try it — starter oscillator

Click Play to start audio

3. MIDI

MIDI path

WaxNative handles MIDI notes from the DAW and your on-screen keyboard through the same input, wax.midinotein(). Both sources use the same voice allocation and sound generation code. The following steps show how to assign incoming notes to voices and use their pitch and velocity to drive an oscillator and envelope.

MIDI voices (step by step)

Host and UI MIDI share one path: collect notes, assign voices, read pitch and level, then envelope and oscillator. Steps:

  1. midinotein() — note stream from the host, wax.midi, or internal event.
  2. midinoteallocate({ voices: N }, midiIn) — polyphony: assign each note-on to one of N voices.
  3. midinoteunpack({ channel: 0 }, voices) — per voice, pitch (frequency) and strength (velocity).
  4. trigger — from wax.ge(velocity, …): start/stop event for the ADSR. 1 while key down, 0 when up.
  5. wax.latch(trigger, frequency) — memory node. When trigger turns on, holds that note’s frequency for the osc through release (frequency jumps to 0 on release).
  6. wax.adsr(…, trigger) — amplitude envelope. The same trigger starts attack and ends sustain, then runs for release.
const midiIn = wax.midinotein(); // host / wax.midi / internal events
const voices = wax.midinoteallocate({ voices: 8 }, midiIn); // assign note-ons to 8 slots
const [frequency, velocity] = wax.midinoteunpack({ channel: 0 }, voices); // pitch + level (voice 0)

const trigger = wax.ge(velocity, wax.const({ key: "triggerEps", value: 0.001 })); // 1 while key down, 0 default
const oscFreq = wax.latch(trigger, frequency); // memory node: last note-on Hz for release
const ampEnv = wax.adsr(attack, decay, sustain, release, trigger); // envelope follows trigger
const tone = wax.mul(ampEnv, wax.mul(velocity, wax.cycle(oscFreq)));

On-screen keyboard

await wax.midi.ready();
wax.midi.noteOn(60, 100);
wax.midi.noteOff(60);

Control change (CC)

After await wax.midi.ready(), wax.midi.access().inputs deliver ordinary MIDI bytes from the host — not only notes. Control Change uses status 0xB0 (channel in the low nibble), then controller number and value (**0–127** each). That stream is separate from midinotein(): CC does not become note voices; you handle it in JavaScript. Audio still updates only when you call setParam (or use wax.param in the graph).

Receive control change

Listen on each input and ignore non-CC status bytes. Map a controller into the same setParam / params as Get started — that rebuilds the graph (e.g. CC 1 → gain).

wax.midi.ready().then(function () {
  const access = wax.midi.access();
  if (!access) return;

  access.inputs.forEach(function (input) {
    input.onmidimessage = function (e) {
      if ((e.data[0] & 0xf0) !== 0xb0) return; // not Control Change

      const controller = e.data[1];
      const value = e.data[2]; // 0–127
      if (controller === 1) setParam("gain", value / 127);
    };
  });
});

Send UI changes to the host

When the user moves a slider, update the local parameter and send the corresponding CC value to the host. wax.midi.cc takes a controller number from 1–32 for these host controls and a MIDI value from 0–127.

Register this listener after MIDI is ready. It replaces the local-only slider listener from a minimal starter.

wax.midi.ready().then(function () {
  slider.addEventListener("input", function () {
    if (hostCcFromHost) return;

    const gain = Number(slider.value);
    setParam("gain", gain);
    wax.midi.cc(1, Math.round(gain * 127));
  });
});

setParam updates the graph; wax.midi.cc tells the host the parameter moved. Use a hostCcFromHost flag when applying incoming host/CC updates so you do not echo CC back — see Host automation.

Starter page

Monophonic synth from the voice chain above, plus Control change: CC in → setParam, sliders out → wax.midi.cc.

<script src="https://szfpro.github.io/CodeEditorHTML/wax-native.js"></script>
<script>
// One wax instance for the whole page (plugin native or browser hybrid).
let wax = null;

async function init() {
  wax = await WaxNative.create();
  renderDevice();
  await wax.midi.ready(); // notes (wax.midi.noteOn) and CC (access.inputs)
  wireCc();
}

// Build the note-driven chain — call again whenever params change.
function renderDevice() {
  const midiIn = wax.midinotein();
  const voices = wax.midinoteallocate({ voices: 1 }, midiIn);
  const [frequency, velocity] = wax.midinoteunpack({ channel: 0 }, voices);
  const trigger = wax.ge(velocity, wax.const({ key: "triggerEps", value: 0.001 }));
  const oscFreq = wax.latch(trigger, frequency);
  const env = wax.adsr(params.attack, 0.1, 1, params.release, trigger);
  const tone = wax.mul(env, wax.mul(velocity, wax.cycle(oscFreq)));
  const out = wax.mul(params.master, tone);
  wax.render(out, out);
}

const params = { master: 0.2, attack: 0.02, release: 0.35 };

let renderTimer = null;
function scheduleRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = setTimeout(function () {
    renderTimer = null;
    renderDevice();
  }, 16);
}
function flushRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = null;
  renderDevice();
}

function setParam(name, value) {
  params[name] = value;
  scheduleRender();
}

let masterSlider = null;
let attackSlider = null;
let releaseSlider = null;
let hostCcFromHost = false;

// CC 1–3 → params (0–127 MIDI → slider ranges below).
function applyCc(controller, midiValue) {
  hostCcFromHost = true;
  const t = midiValue / 127;
  if (controller === 1) {
    params.master = t * 0.5;
    if (masterSlider) masterSlider.value = String(params.master);
  }
  if (controller === 2) {
    params.attack = 0.005 + t * (0.5 - 0.005);
    if (attackSlider) attackSlider.value = String(params.attack);
  }
  if (controller === 3) {
    params.release = 0.05 + t * (1.5 - 0.05);
    if (releaseSlider) releaseSlider.value = String(params.release);
  }
  hostCcFromHost = false;
  flushRender();
}

function wireCc() {
  const access = wax.midi.access();
  if (!access) return;
  access.inputs.forEach(function (input) {
    input.onmidimessage = function (e) {
      if ((e.data[0] & 0xf0) !== 0xb0) return;
      applyCc(e.data[1], e.data[2]);
    };
  });
}

// Wire sliders, then boot.
document.addEventListener("DOMContentLoaded", function () {
  masterSlider = document.getElementById("master");
  attackSlider = document.getElementById("attack");
  releaseSlider = document.getElementById("release");

  masterSlider.addEventListener("input", function () {
    if (hostCcFromHost) return;
    const v = Number(masterSlider.value);
    setParam("master", v);
    wax.midi.cc(1, Math.round((v / 0.5) * 127));
  });
  masterSlider.addEventListener("change", function () {
    if (hostCcFromHost) return;
    flushRender();
  });
  attackSlider.addEventListener("input", function () {
    if (hostCcFromHost) return;
    const v = Number(attackSlider.value);
    setParam("attack", v);
    wax.midi.cc(2, Math.round(((v - 0.005) / (0.5 - 0.005)) * 127));
  });
  attackSlider.addEventListener("change", function () {
    if (hostCcFromHost) return;
    flushRender();
  });
  releaseSlider.addEventListener("input", function () {
    if (hostCcFromHost) return;
    const v = Number(releaseSlider.value);
    setParam("release", v);
    wax.midi.cc(3, Math.round(((v - 0.05) / (1.5 - 0.05)) * 127));
  });
  releaseSlider.addEventListener("change", function () {
    if (hostCcFromHost) return;
    flushRender();
  });

  init();
});
</script>

<label>Master
  <input id="master" type="range" min="0" max="0.5" step="0.01" value="0.2" />
</label>
<label>Attack (s)
  <input id="attack" type="range" min="0.005" max="0.5" step="0.005" value="0.02" />
</label>
<label>Release (s)
  <input id="release" type="range" min="0.05" max="1.5" step="0.01" value="0.35" />
</label>

Try it — mini synth (MIDI → ADSR)

Click Play, then hold keys or use your computer keyboard (A S D F G)

4. Automation

Connect the DAW’s host parameter slots to your on-screen sliders and params so automation, project recall, and user moves all update the same audio.

Host automation

Host automation lets the DAW control your device’s parameters through cc1–cc32. Map these controls to the same params used by your UI so automation updates both the audio and the displayed values.

Walk through sync, send, receive, and range mapping below. Uses params / setParam from Get started.

Sync values on load

When the device loads inside the plugin, request the host’s current parameter values. This initializes the device and its controls from the host’s state, including values restored from a saved project.

Define a handler, in this case hostUpdate, that calls setParam to update the audio and assigns the slider’s value to update the UI. Register it when WaxNative.hasBridge() returns true, then request the initial sync. The response contains cc1–cc32 values normalized to 0–1.

let hostCcFromHost = false;

function hostUpdate(host) {
  hostCcFromHost = true;
  if (host.cc1 != null) {
    setParam("gain", host.cc1);
    slider.value = host.cc1;
  }
  hostCcFromHost = false;
}

if (WaxNative.hasBridge()) {
  window.WAX._internal.receiveWaxNativeUpdate = hostUpdate;
  WaxNative.emitEvent("waxNativeRequestParamSync", {});
}

Send automation changes

When the user moves a slider, update the graph with setParam and notify the host with wax.midi.cc. Use controller numbers 1–32 (host parameter slots) and MIDI values 0–127. Wire listeners after await wax.midi.ready() — same MIDI API as Control change (CC).

wax.midi.ready().then(function () {
  gainSlider.addEventListener("input", function () {
    if (hostCcFromHost) return;

    setParam("gain", Number(gainSlider.value));
    wax.midi.cc(1, Math.round(params.gain * 127));
  });
});

setParam drives audio; wax.midi.cc updates host state and automation. Set hostCcFromHost while applying sync or incoming values so you do not echo CC back to the host.

Receive automation changes

The sync request retrieves the current values once. Subsequent automation changes arrive as MIDI CC messages, with values from 0–127. Live CC uses the same hostUpdate as sync; only the payload differs (MIDI bytes you normalize to 0–1 vs the bridge object from sync).

After MIDI is ready, listen for CC messages, normalize their values to 0–1, and pass them to hostUpdate. This keeps the parameter mapping and UI updates in one place.

wax.midi.ready().then(function () {
  const access = wax.midi.access();
  if (!access) return;

  access.inputs.forEach(function (input) {
    input.onmidimessage = function (e) {
      if ((e.data[0] & 0xf0) !== 0xb0) return;

      const controller = e.data[1];
      const value = e.data[2] / 127;

      hostUpdate({ ["cc" + controller]: value });
    };
  });
});

Map parameter ranges

For parameters with a different range, convert incoming normalized values into the parameter’s units and convert outgoing values back to MIDI’s 0–127 range.

For example, map cc2 to a frequency control spanning 50–2000 Hz:

// Inside hostUpdate: normalized host value → Hz.
if (host.cc2 != null) {
  const frequency = 50 + host.cc2 * (2000 - 50);
  setParam("frequency", frequency);
  frequencySlider.value = frequency;
}

// Inside the frequency slider's input handler: Hz → MIDI CC.
const normalized = (params.frequency - 50) / (2000 - 50);
wax.midi.cc(2, Math.round(normalized * 127));

Use matching conversions in both directions so the host and UI represent the same value. To read host CC inside the graph with wax.param, see Reference — wax.param.

Wire it together

Define a method to bundle host-side wiring, in this case wireHost(), and call it from init() after WaxNative.create(). Put Sync values on load and the CC listener from Receive automation changes in that function — both call the same hostUpdate. Attach slider input handlers in DOM setup (Send automation changes). They call setParam and wax.midi.cc when the user moves a control.

function wireHost() {
  if (WaxNative.hasBridge()) {
    window.WAX._internal.receiveWaxNativeUpdate = hostUpdate;
    WaxNative.emitEvent("waxNativeRequestParamSync", {});
  }

  wax.midi.ready().then(function () {
    const access = wax.midi.access();
    if (!access) return;
    access.inputs.forEach(function (input) {
      input.onmidimessage = function (e) {
        if ((e.data[0] & 0xf0) !== 0xb0) return;
        hostUpdate({ ["cc" + e.data[1]]: e.data[2] / 127 });
      };
    });
  });
}

async function init() {
  wax = await WaxNative.create();
  renderDevice();
  wireHost();
}

document.addEventListener("DOMContentLoaded", function () {
  // gainSlider.addEventListener("input", …) — see Send automation changes
  init();
});

Starter page

Same always-on oscillator as Get started, plus wireHost() so DAW automation and incoming CC stay in sync with the sliders. Project / hub URLs: Troubleshooting.

<script src="https://szfpro.github.io/CodeEditorHTML/wax-native.js"></script>
<script>
// One wax instance for the whole page (plugin native or browser hybrid).
let wax = null;

async function init() {
  wax = await WaxNative.create();
  renderDevice();
  wireHost(); // sync on load + listen for host CC (inside wireHost)
}

// Same always-on oscillator as Get started — rebuild when params change.
function renderDevice() {
  const tone = wax.mul(params.gain, wax.cycle(params.frequency));
  wax.render(tone, tone);
}

const params = { gain: 0.15, frequency: 440 };

let renderTimer = null;
function scheduleRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = setTimeout(function () {
    renderTimer = null;
    renderDevice();
  }, 16);
}
function flushRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = null;
  renderDevice();
}

function setParam(name, value) {
  params[name] = value;
  scheduleRender();
}

// Slider refs for hostUpdate; guard stops echo when host moves a knob.
let gainSlider = null;
let freqSlider = null;
let hostCcFromHost = false;

// Host → params + on-screen sliders (cc1–cc32 values are 0–1).
function hostUpdate(host) {
  hostCcFromHost = true;
  if (host.cc1 != null) {
    params.gain = host.cc1;
    if (gainSlider) gainSlider.value = String(params.gain);
  }
  if (host.cc2 != null) {
    params.frequency = 50 + host.cc2 * (2000 - 50); // normalized → Hz
    if (freqSlider) freqSlider.value = String(params.frequency);
  }
  hostCcFromHost = false;
  flushRender();
}

function wireHost() {
  // One-shot project/host state (see Sync values on load).
  if (WaxNative.hasBridge()) {
    window.WAX._internal.receiveWaxNativeUpdate = hostUpdate;
    WaxNative.emitEvent("waxNativeRequestParamSync", {});
  }

  // Live automation as MIDI CC → same hostUpdate.
  wax.midi.ready().then(function () {
    const access = wax.midi.access(); // null in a plain browser
    if (!access) return;
    access.inputs.forEach(function (input) {
      input.onmidimessage = function (e) {
        if ((e.data[0] & 0xf0) !== 0xb0) return;
        hostUpdate({ ["cc" + e.data[1]]: e.data[2] / 127 });
      };
    });
  });
}

// Wire sliders, then boot.
document.addEventListener("DOMContentLoaded", function () {
  gainSlider = document.getElementById("gain");
  freqSlider = document.getElementById("frequency");
  gainSlider.addEventListener("input", function () {
    if (hostCcFromHost) return;
    setParam("gain", Number(gainSlider.value));
    if (wax && wax.midi) wax.midi.cc(1, Math.round(params.gain * 127));
  });
  gainSlider.addEventListener("change", function () {
    if (hostCcFromHost) return;
    flushRender();
  });
  freqSlider.addEventListener("input", function () {
    if (hostCcFromHost) return;
    setParam("frequency", Number(freqSlider.value));
    if (wax && wax.midi) {
      const normalized = (params.frequency - 50) / (2000 - 50);
      wax.midi.cc(2, Math.round(normalized * 127));
    }
  });
  freqSlider.addEventListener("change", function () {
    if (hostCcFromHost) return;
    flushRender();
  });
  init();
});
</script>

<label>Gain
  <input id="gain" type="range" min="0" max="1" step="0.01" value="0.15" />
</label>
<label>Frequency (Hz)
  <input id="frequency" type="range" min="50" max="2000" step="1" value="440" />
</label>

Try it — oscillator + host CC

Click Play to start audio

5. Presets

DataTree flow

Presets and session recall go through WAX’s DataTree storage. You choose the JSON snapshot; the host does not store a frozen copy of the WaxNative graph. Save only what renderDevice() reads (usually params plus any mode flags your graph needs).

Not stored: the instruction batch from wax.render() — only your JSON.

  1. Save — debounced push after user edits; onPull before host preset save.
  2. Recall — first emit, then pull and apply when wax.didEmit() is true (see Boot order).
  3. Sound — graph always follows JS state; recall must update the same variables renderDevice() reads.

What gets saved

DataTree stores your JSON settings, not the instruction batch from wax.render().

Put musically meaningful knobs and modes under params:

{
  "params": {
    "gain": 0.15,
    "frequency": 440,
    "cutoff": 1200,
    "resonance": 0.8
  }
}

Do not store meter needles, scope buffers, transport position, or animation frames unless you explicitly want them recalled. Do not invent DataTree.subscribe or private host APIs — use window.WAX_DataTree (see below).

appName

Every push and pull needs a stable, unique string per device page. Two pages sharing the same name overwrite each other’s snapshots.

const APP_NAME = "my-wax-synth";

Pick one ID and keep it for the life of the project (renaming breaks recall for old sessions).

Save and load

The host injects window.WAX_DataTree. Typical methods:

  • push(data, APP_NAME) — send your JSON to the host (debounce ~250 ms after user changes).
  • pull(APP_NAME) — returns a Promise with the last saved blob (on boot / recall).
  • onPull(handler) — host asks for fresh state before save; call push(collectState(), APP_NAME) inside the handler.

Apply must touch params. If renderDevice() reads JavaScript variables, updating only the range inputs leaves recall looking correct but sounding wrong. Mirror Get started: write DOM and params, then renderDevice() (or flushRender() if you debounce).

function collectState() {
  return { params: { gain: params.gain, frequency: params.frequency } };
}

function applyState(raw) {
  const p = raw && raw.params ? raw.params : raw;
  if (!p || typeof p !== "object") return;
  if (p.gain != null) params.gain = Number(p.gain);
  if (p.frequency != null) params.frequency = Number(p.frequency);
  if (gainSlider) gainSlider.value = String(params.gain);
  if (freqSlider) freqSlider.value = String(params.frequency);
  renderDevice();
}

let pushTimer = null;
function pushSoon() {
  if (pushTimer) clearTimeout(pushTimer);
  pushTimer = setTimeout(function () {
    pushTimer = null;
    if (window.WAX_DataTree && window.WAX_DataTree.push) {
      window.WAX_DataTree.push(collectState(), APP_NAME);
    }
  }, 250);
}

Call pushSoon() from slider change handlers (not every input tick unless you debounce). Register onPull once during init so host preset save stays in sync.

Boot order

Recall flow: host restores URL + cached DataTree → page creates wax → first renderDevice() → pull → apply → render again.

Do not run heavy apply before the bridge accepts the first emit. If apply races boot, you can get Emit: failed and silence (see Poke & didEmit).

  1. Wire HTML defaults and call renderDevice() after WaxNative.create().
  2. Defer DataTree setup — typically setTimeout(initDataTree, 600), not immediately at parse time.
  3. On pull, call applyState only when wax.didEmit() is true; if not, retry apply once after ~150 ms.
function pullAndApply() {
  if (!window.WAX_DataTree || !window.WAX_DataTree.pull) return;
  window.WAX_DataTree.pull(APP_NAME).then(function (raw) {
    if (!raw) return;
    if (!wax.didEmit()) {
      setTimeout(function () {
        if (wax.didEmit()) applyState(raw);
      }, 150);
      return;
    }
    applyState(raw);
  });
}

function initDataTree() {
  const dt = window.WAX_DataTree;
  if (!dt) return;
  if (typeof dt.onPull === "function") {
    dt.onPull(function () {
      dt.push(collectState(), APP_NAME);
    });
  }
  pullAndApply();
}

async function init() {
  wax = await WaxNative.create();
  renderDevice();
  setTimeout(initDataTree, 600);
}

Example script

Minimal preset wiring on the always-on oscillator from Get started: collect / apply / debounced push, deferred init, gated pull.

<script src="https://szfpro.github.io/CodeEditorHTML/wax-native.js"></script>
<script>
const APP_NAME = "wax-doc-starter-presets";
let wax = null;
const params = { gain: 0.15, frequency: 440 };

function collectState() {
  return { params: { gain: params.gain, frequency: params.frequency } };
}

function applyState(raw) {
  const p = raw && raw.params ? raw.params : raw;
  if (!p || typeof p !== "object") return;
  if (p.gain != null) params.gain = Number(p.gain);
  if (p.frequency != null) params.frequency = Number(p.frequency);
  const gainSlider = document.getElementById("gain");
  const freqSlider = document.getElementById("frequency");
  if (gainSlider) gainSlider.value = String(params.gain);
  if (freqSlider) freqSlider.value = String(params.frequency);
  renderDevice();
}

let pushTimer = null;
function pushSoon() {
  if (pushTimer) clearTimeout(pushTimer);
  pushTimer = setTimeout(function () {
    pushTimer = null;
    if (window.WAX_DataTree && window.WAX_DataTree.push) {
      window.WAX_DataTree.push(collectState(), APP_NAME);
    }
  }, 250);
}

function pullAndApply() {
  const dt = window.WAX_DataTree;
  if (!dt || !dt.pull) return;
  dt.pull(APP_NAME).then(function (raw) {
    if (!raw) return;
    if (!wax.didEmit()) {
      setTimeout(function () {
        if (wax.didEmit()) applyState(raw);
      }, 150);
      return;
    }
    applyState(raw);
  });
}

function initDataTree() {
  const dt = window.WAX_DataTree;
  if (!dt) return;
  if (typeof dt.onPull === "function") {
    dt.onPull(function () {
      dt.push(collectState(), APP_NAME);
    });
  }
  pullAndApply();
}

function renderDevice() {
  const tone = wax.mul(params.gain, wax.cycle(params.frequency));
  wax.render(tone, tone);
}

async function init() {
  wax = await WaxNative.create();
  renderDevice();
  setTimeout(initDataTree, 600);
}

document.addEventListener("DOMContentLoaded", function () {
  const gainSlider = document.getElementById("gain");
  const freqSlider = document.getElementById("frequency");
  gainSlider.addEventListener("input", function () {
    params.gain = Number(gainSlider.value);
  });
  gainSlider.addEventListener("change", function () {
    renderDevice();
    pushSoon();
  });
  freqSlider.addEventListener("input", function () {
    params.frequency = Number(freqSlider.value);
  });
  freqSlider.addEventListener("change", function () {
    renderDevice();
    pushSoon();
  });
  init();
});
</script>

<label>Gain
  <input id="gain" type="range" min="0" max="1" step="0.01" value="0.15" />
</label>
<label>Frequency (Hz)
  <input id="frequency" type="range" min="50" max="2000" step="1" value="440" />
</label>

Try it — oscillator + DataTree push / pull

Click Play — Push or slider change saves; Recall runs pull → applyState

6. Instruments — audio & MIDI

An instrument makes sound when you play notes. MIDI showed how notes arrive; Automation how sliders talk to the host. Here you stack the audio blocks inside renderDevice(): turn each note into pitch and loudness, pick a waveform, optionally filter it, then wax.render.

Signal path

Build the node chain in this order:

  1. Notes — midinotein through allocate / unpack (see MIDI voices).
  2. Envelope — trigger, latch, and adsr so keys fade in and out smoothly.
  3. Oscillator — a wave at oscFreq, scaled by velocity and the envelope.
  4. Tone shaping — often wax.svf (lowpass cutoff).
  5. Output — master gain, then wax.render(left, right).

On-screen keys use wax.midi.noteOn / noteOff (Keyboard); they feed the same midinotein path as the DAW.

Notes, envelope, and level

Start every instrument’s renderDevice() from the note stream. One voice is enough for a lead synth; raise voices for polyphony.

const midiIn = wax.midinotein();
const voices = wax.midinoteallocate({ voices: 1 }, midiIn);
const [frequency, velocity] = wax.midinoteunpack({ channel: 0 }, voices);

const trigger = wax.ge(velocity, wax.const({ key: "triggerEps", value: 0.001 }));
const oscFreq = wax.latch(trigger, frequency);

const env = wax.adsr(
  wax.const({ key: "attack", value: params.attack }),
  wax.const({ key: "decay", value: 0.1 }),
  wax.const({ key: "sustain", value: 1 }),
  wax.const({ key: "release", value: params.release }),
  trigger,
);
// oscFreq + env + velocity → oscillator section next

velocity is how hard the note was played (0 when no key). env is the amplitude shape over time. You will multiply both into the oscillator output.

Oscillators

The oscillator is a steady wave whose pitch comes from oscFreq (Hz). Pick a generator in JavaScript — a dropdown or buttons set params.wave, then call the matching wax.* node:

const params = {
  wave: "saw",
  attack: 0.02,
  release: 0.35,
  master: 0.2,
  cutoff: 2400,
};

function pickOscillator(freq) {
  switch (params.wave) {
    case "sine":
      return wax.cycle(freq);
    case "square":
      return wax.square(freq);
    case "triangle":
      return wax.triangle(freq);
    case "saw":
    default:
      return wax.saw(freq);
  }
}

// Inside renderDevice(), after the envelope block above:
const raw = pickOscillator(oscFreq);
const tone = wax.mul(env, wax.mul(velocity, raw));

Changing wave re-renders the graph — same as any other knob: update params.wave and call renderDevice().

SoundNodeNotes
SinecycleSmooth, soft
SawsawBright, common for synths
SquaresquareHollow, retro
TriangletriangleMellow
Anti-aliased saw / square / triangleblepsaw, blepsquare, bleptriangleCleaner when pitch bends
Noisenoise, pinknoiseDrums / texture — often not tied to oscFreq

Tone shaping

A state-variable filter tames brightness. Store cutoff in params and pass it with wax.const each render — same pattern as attack / master in the blocks above.

const filtered = wax.svf(
  { mode: "lowpass" },
  wax.const({ key: "cutoff", value: params.cutoff }),
  wax.const({ key: "resonance", value: 0.7 }),
  tone,
);
const out = wax.mul(wax.const({ key: "master", value: params.master }), filtered);
wax.render(out, out);

More filter and dynamics examples live under Effects.

Host parameters

A parameter controls audio from the page UI or from the DAW. Read more in Host automation.

In this instrument, parameters are:

  • attack, release — envelope times (seconds)
  • master — output level
  • cutoff — lowpass frequency (Hz)
  • wave — oscillator shape (pickOscillator)
  • voices — polyphony (1–8)

The example below maps host CC to every parameter: cc1 cutoff, cc2 master, cc3 attack, cc4 release, cc5 wave (same pattern as the Automation starter).

Example script

Full synth (1–8 voices): allocate → envelope → pickOscillator → SVF → mix → wax.render.

let wax = null;

const params = {
  wave: "saw",
  master: 0.2,
  cutoff: 2400,
  attack: 0.02,
  release: 0.35,
  voices: 1,
};

const WAVES = ["sine", "square", "triangle", "saw"];

let cutoffSlider = null;
let masterSlider = null;
let attackSlider = null;
let releaseSlider = null;
let waveSelect = null;
let voicesInput = null;
let hostCcFromHost = false;

function pickOscillator(freq) {
  switch (params.wave) {
    case "sine": return wax.cycle(freq);
    case "square": return wax.square(freq);
    case "triangle": return wax.triangle(freq);
    case "saw":
    default: return wax.saw(freq);
  }
}

let renderTimer = null;
function scheduleRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = setTimeout(function () {
    renderTimer = null;
    renderDevice();
  }, 16);
}
function flushRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = null;
  renderDevice();
}

function setParam(name, value) {
  params[name] = value;
  scheduleRender();
}

function hostUpdate(host) {
  hostCcFromHost = true;
  if (host.cc1 != null) {
    params.cutoff = 200 + host.cc1 * (8000 - 200);
    if (cutoffSlider) cutoffSlider.value = String(params.cutoff);
  }
  if (host.cc2 != null) {
    params.master = host.cc2 * 0.5;
    if (masterSlider) masterSlider.value = String(params.master);
  }
  if (host.cc3 != null) {
    params.attack = 0.005 + host.cc3 * (0.5 - 0.005);
    if (attackSlider) attackSlider.value = String(params.attack);
  }
  if (host.cc4 != null) {
    params.release = 0.05 + host.cc4 * (1.5 - 0.05);
    if (releaseSlider) releaseSlider.value = String(params.release);
  }
  if (host.cc5 != null) {
    params.wave = WAVES[Math.min(3, Math.floor(host.cc5 * 4))];
    if (waveSelect) waveSelect.value = params.wave;
  }
  hostCcFromHost = false;
  flushRender();
}

function wireHost() {
  if (WaxNative.hasBridge()) {
    window.WAX._internal.receiveWaxNativeUpdate = hostUpdate;
    WaxNative.emitEvent("waxNativeRequestParamSync", {});
  }
  wax.midi.ready().then(function () {
    const access = wax.midi.access();
    if (!access) return;
    access.inputs.forEach(function (input) {
      input.onmidimessage = function (e) {
        if ((e.data[0] & 0xf0) !== 0xb0) return;
        hostUpdate({ ["cc" + e.data[1]]: e.data[2] / 127 });
      };
    });
  });
}

function renderDevice() {
  const midiIn = wax.midinotein();
  const allocated = wax.midinoteallocate({ voices: params.voices }, midiIn);
  let mix = null;

  for (let i = 0; i < params.voices; i++) {
    const [frequency, velocity] = wax.midinoteunpack({ channel: i }, allocated);
    const trigger = wax.ge(
      velocity,
      wax.const({ key: "triggerEps" + i, value: 0.001 }),
    );
    const oscFreq = wax.latch(trigger, frequency);
    const env = wax.adsr(
      wax.const({ key: "attack", value: params.attack }),
      wax.const({ key: "decay", value: 0.1 }),
      wax.const({ key: "sustain", value: 1 }),
      wax.const({ key: "release", value: params.release }),
      trigger,
    );
    const tone = wax.mul(env, wax.mul(velocity, pickOscillator(oscFreq)));
    const filtered = wax.svf(
      { mode: "lowpass" },
      wax.const({ key: "cutoff", value: params.cutoff }),
      wax.const({ key: "resonance", value: 0.7 }),
      tone,
    );
    mix = mix ? wax.add(mix, filtered) : filtered;
  }

  const out = wax.mul(wax.const({ key: "master", value: params.master }), mix);
  wax.render(out, out);
}

async function init() {
  wax = await WaxNative.create();
  renderDevice();
  wireHost();
  await wax.midi.ready();
}

document.addEventListener("DOMContentLoaded", function () {
  cutoffSlider = document.getElementById("cutoff");
  masterSlider = document.getElementById("master");
  attackSlider = document.getElementById("attack");
  releaseSlider = document.getElementById("release");
  waveSelect = document.getElementById("wave");
  voicesInput = document.getElementById("voices");

  voicesInput.addEventListener("change", function () {
    const n = Math.min(8, Math.max(1, Number(voicesInput.value) | 0));
    voicesInput.value = String(n);
    params.voices = n;
    flushRender();
  });

  cutoffSlider.addEventListener("input", function () {
    if (hostCcFromHost) return;
    setParam("cutoff", Number(cutoffSlider.value));
    if (wax && wax.midi) {
      const n = (params.cutoff - 200) / (8000 - 200);
      wax.midi.cc(1, Math.round(n * 127));
    }
  });
  cutoffSlider.addEventListener("change", function () {
    if (hostCcFromHost) return;
    flushRender();
  });
  masterSlider.addEventListener("input", function () {
    if (hostCcFromHost) return;
    setParam("master", Number(masterSlider.value));
    if (wax && wax.midi) wax.midi.cc(2, Math.round((params.master / 0.5) * 127));
  });
  masterSlider.addEventListener("change", function () {
    if (hostCcFromHost) return;
    flushRender();
  });
  attackSlider.addEventListener("input", function () {
    if (hostCcFromHost) return;
    setParam("attack", Number(attackSlider.value));
    if (wax && wax.midi) {
      wax.midi.cc(3, Math.round(((params.attack - 0.005) / (0.5 - 0.005)) * 127));
    }
  });
  attackSlider.addEventListener("change", function () {
    if (hostCcFromHost) return;
    flushRender();
  });
  releaseSlider.addEventListener("input", function () {
    if (hostCcFromHost) return;
    setParam("release", Number(releaseSlider.value));
    if (wax && wax.midi) {
      wax.midi.cc(4, Math.round(((params.release - 0.05) / (1.5 - 0.05)) * 127));
    }
  });
  releaseSlider.addEventListener("change", function () {
    if (hostCcFromHost) return;
    flushRender();
  });
  waveSelect.addEventListener("change", function () {
    if (hostCcFromHost) return;
    setParam("wave", waveSelect.value);
    if (wax && wax.midi) {
      const idx = WAVES.indexOf(params.wave);
      wax.midi.cc(5, Math.round((idx < 0 ? 0 : idx) / 3 * 127));
    }
    flushRender();
  });

  init();
});
<label>Voices
  <input id="voices" type="number" min="1" max="8" step="1" value="1" />
</label>

Try it — mini synth (MIDI → ADSR → filter)

Click Play, then hold keys or use your computer keyboard (A S D F G)

7. Effects

An effect processes audio that is already on the track. The only structural difference from an instrument is where the signal starts: wax.in instead of an oscillator.

Track input

On a track effect, incoming audio from the DAW enters the graph with wax.in({ channel: 0 }) (use channel 1 for the right side of a stereo input).

const input = wax.in({ channel: 0 });

Browser mic (preview only)

await wax.connectMic();  // after a user click — then wax.in hears the mic in Chrome

Filters and dynamics

wax.svf({ mode }, fc, q, x) — modes: lowpass, highpass, bandpass, notch, allpass. Also shelves, compress, delay, convolve.

Example effect

function renderDevice() {
  const input = wax.in({ channel: 0 });
  const filtered = wax.svf(
    { mode: "lowpass" },
    wax.const({ key: "cutoff", value: cutoff }),
    wax.const({ key: "resonance", value: resonance }),
    input
  );
  const output = wax.mul(wax.const({ key: "gain", value: gain }), filtered);
  wax.render(output, output);
}

Try it — lowpass on wax.in

Click Play — browser preview uses your mic (or a test tone) into wax.in

8. Sequencing

Repeating patterns — arpeggios, step sequencers, timed gates — belong in the graph, not in JavaScript. Build the clock and seq2 once in renderDevice(); the engine keeps time after the editor closes. Notes and envelopes reuse the same blocks as Instruments; do not advance steps with setInterval.

Signal path

Wire the chain in this order:

  1. Clock — train (or metro) at step rate derived from BPM.
  2. Steps — seq2 for pitch (hold) and gate (pulse) arrays, looped.
  3. Envelope — ge on the gate → adsr (same idea as Notes, envelope, and level).
  4. Oscillator — cycle at the pitch signal from seq2.
  5. Output — master gain → wax.render.

Clock and tempo

Convert host BPM to step rate in Hz (one step per quarter note at 4/4: bpm / 60). Drive train from a wax.const so transport can set rate to 0 on stop. Keep key strings stable; read params.playing and params.bpm inside renderDevice().

const stepHz = params.playing ? params.bpm / 60 : 0;
const clock = wax.train(wax.const({ key: "stepHz", value: stepHz }));

Steps and gates

seq2 steps through an array of numbers, advancing one step on each clock pulse. Use two sequences driven by the same clock: one to choose the pitch, and another to trigger the envelope.

  • seq — the values for each step. For pitch, the example uses frequencies in Hz. For triggers, use 1 to play a step and 0 to skip it.
  • hold: true — keep the current value until the next step. Use this for pitch so the oscillator keeps its frequency between clock pulses.
  • hold: false — output a short pulse, then return to zero. Use this to trigger the envelope on each active step.
  • loop: true — repeat the pattern after its last step.

Pass the clock after the options object, followed by the optional reset signal.

const reset = wax.const({ key: "seqReset", value: 0 });
const pitch = wax.seq2(
  { key: "pitch", seq: STEP_PITCHES, hold: true, loop: true },
  clock,
  reset
);
const gate = wax.seq2(
  { key: "gate", seq: [1, 0, 1, 0], hold: false, loop: true },
  clock,
  reset
);
const trigger = wax.ge(gate, wax.const({ key: "trigEps", value: 0.001 }));

Host transport

The DAW calls globals when transport or tempo changes. Update params, then call renderDevice() immediately — not on requestAnimationFrame. On stop, set params.playing to 0 so step rate goes to zero and the clock freezes.

Play and stop

window.WAX_Play = function () {
  params.playing = 1;
  renderDevice();
};
window.WAX_Stop = function () {
  params.playing = 0;
  renderDevice();
};

BPM

window.WAX_BPM = function (bpm) {
  params.bpm = bpm;
  renderDevice();
};

Wire transport

Assign the globals once after WaxNative.create(). In browser preview, also set params.playing from your Play button so the seq runs without the DAW.

function wireTransport() {
  window.WAX_Play = function () {
    params.playing = 1;
    flushRender();
  };
  window.WAX_Stop = function () {
    params.playing = 0;
    flushRender();
  };
  window.WAX_BPM = function (bpm) {
    params.bpm = bpm;
    flushRender();
  };
}

Example script

const STEP_PITCHES = [261.63, 329.63, 392.0, 493.88];
const params = { bpm: 120, master: 0.2, playing: 0 };

let renderTimer = null;
function scheduleRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = setTimeout(function () {
    renderTimer = null;
    renderDevice();
  }, 16);
}
function flushRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = null;
  renderDevice();
}

function setParam(name, value) {
  params[name] = value;
  scheduleRender();
}

function renderDevice() {
  const stepHz = params.playing ? params.bpm / 60 : 0;
  const clock = wax.train(wax.const({ key: "stepHz", value: stepHz }));
  const reset = wax.const({ key: "seqReset", value: 0 });
  const pitch = wax.seq2(
    { key: "pitch", seq: STEP_PITCHES, hold: true, loop: true },
    clock,
    reset
  );
  const gate = wax.seq2(
    { key: "gate", seq: [1, 0, 1, 0], hold: false, loop: true },
    clock,
    reset
  );
  const trigger = wax.ge(gate, wax.const({ key: "trigEps", value: 0.001 }));
  const env = wax.adsr(
    wax.const({ key: "attack", value: 0.005 }),
    wax.const({ key: "decay", value: 0.08 }),
    wax.const({ key: "sustain", value: 0.75 }),
    wax.const({ key: "release", value: 0.12 }),
    trigger
  );
  const tone = wax.mul(
    wax.const({ key: "master", value: params.master }),
    wax.mul(env, wax.cycle(pitch))
  );
  wax.render(tone, tone);
}

function wireTransport() {
  window.WAX_Play = function () {
    params.playing = 1;
    flushRender();
  };
  window.WAX_Stop = function () {
    params.playing = 0;
    flushRender();
  };
  window.WAX_BPM = function (bpm) {
    params.bpm = bpm;
    flushRender();
  };
}

async function init() {
  wax = await WaxNative.create();
  wireTransport();
  renderDevice();
}

document.addEventListener("DOMContentLoaded", function () {
  const bpmSlider = document.getElementById("bpm");
  const masterSlider = document.getElementById("master");
  bpmSlider.addEventListener("input", function () {
    setParam("bpm", Number(bpmSlider.value));
  });
  bpmSlider.addEventListener("change", flushRender);
  masterSlider.addEventListener("input", function () {
    setParam("master", Number(masterSlider.value));
  });
  masterSlider.addEventListener("change", flushRender);
  init();
});
<label>BPM
  <input id="bpm" type="range" min="60" max="180" step="1" value="120" />
</label>
<label>Master
  <input id="master" type="range" min="0" max="0.5" step="0.01" value="0.2" />
</label>

Try it — four-step mono seq (train → seq2 → ADSR)

Click Play — seq runs in browser; in the plugin, DAW transport drives params.playing

9. Scopes

Three steps

Audio runs in the engine — you cannot read output samples from JavaScript directly. Tap the signal with a scope node; the host delivers blocks of samples to your page for meters and waveforms. You always do the same three things:

StepYouWhat happens
1. Hook scopewax.scope + wax.renderEngine measures the signal each audio block.
2. Catch blockswireScopeEvents() → scopeSamplesEngine pushes the latest block; you copy it into a JS array.
3. DrawrequestAnimationFrame + canvasPaint from scopeSamples (the last block you were handed).

Step 2 is not extra ceremony — it is “take scope values.” They arrive asynchronously (callback / message), not as a property on the graph node. Do not invent DOM events or DataTree hooks for meters; use the wiring below.

Step 1 — Hook scope (engine measures)

In renderDevice() (or whenever the graph changes), wrap the signal you want to view and render it like any output. Pick a unique name per tap; use the same name when filtering incoming blocks in step 2. size is samples per block; channels is how many arrays appear in payload.data.

const SCOPE_NAME = "main";
const SCOPE_SIZE = 512;
let scopeSamples = null; // filled in step 2, read in step 3

function renderDevice() {
  const tone = wax.mul(params.gain, wax.cycle(params.frequency));
  const out = wax.scope(
    { name: SCOPE_NAME, size: SCOPE_SIZE, channels: 1 },
    tone
  );
  wax.render(out, out);
}

Step 2 — Catch blocks (engine hands you numbers)

Call wireScopeEvents() once at startup (same pattern as wireHost() in Automation). When a block arrives, handleScopePayload copies channel 0 into scopeSamples. Keep this handler fast — no canvas painting here.

function handleScopePayload(payload) {
  const data = typeof payload === "string" ? JSON.parse(payload) : payload;
  if (!data) return;
  const src = data.source || data.name || "";
  if (src && src !== SCOPE_NAME) return;
  const ch0 = Array.isArray(data.data) ? data.data[0] : null;
  if (!ch0 || !ch0.length) return;
  scopeSamples = Float32Array.from(ch0);
}

function wireScopeEvents() {
  function onNativeEvent(name, payload) {
    if (name === "scope") handleScopePayload(payload);
  }
  if (WaxNative.hasBridge()) {
    window.WAX._internal.receiveWaxNativeEvent = onNativeEvent;
  }
  addEventListener("message", function (e) {
    if (e.data && e.data.type === "WAX_NativeEvent" && e.data.name === "scope") {
      handleScopePayload(e.data.payload);
    }
  });
}

Delivery. In the plugin, the host calls receiveWaxNativeEvent on your page. Preview iframes and hybrid Chrome get the same payload via postMessage — that is why both hooks live in wireScopeEvents(). Payload shape: { source, data: [Float32Array, …] }.

Step 3 — Draw (paint the last block)

Start a requestAnimationFrame loop that reads scopeSamples and strokes the canvas. If there is no data yet, clear the canvas and return; blocks will appear once audio is running and step 2 is wired.

function drawScope() {
  requestAnimationFrame(drawScope);
  const canvas = document.getElementById("scope");
  const ctx = canvas.getContext("2d");
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  if (!scopeSamples || !scopeSamples.length) return;
  // stroke waveform from scopeSamples …
}

Example script

Steps 1–3 together: oscillator, gain/frequency sliders (as in Get started), and a canvas. init() wires events, creates wax, renders, and starts the draw loop.

<canvas id="scope" width="320" height="80" style="width:100%;max-width:420px;height:80px;background:#0f1419"></canvas>
<label>Gain
  <input id="gain" type="range" min="0" max="1" step="0.01" value="0.15" />
</label>
<label>Frequency (Hz)
  <input id="frequency" type="range" min="50" max="2000" step="1" value="440" />
</label>

<script src="https://szfpro.github.io/CodeEditorHTML/wax-native.js"></script>
<script>
const SCOPE_NAME = "main";
const SCOPE_SIZE = 512;
let wax = null;
const params = { gain: 0.15, frequency: 440 };
let scopeSamples = null;

// Step 2 — stash each incoming block
function handleScopePayload(payload) {
  try {
    const data = typeof payload === "string" ? JSON.parse(payload) : payload;
    if (!data) return;
    const src = data.source || data.name || "";
    if (src && src !== SCOPE_NAME) return;
    const ch0 = Array.isArray(data.data) ? data.data[0] : null;
    if (!ch0 || !ch0.length) return;
    scopeSamples = Float32Array.from(ch0);
  } catch (_) {}
}

function wireScopeEvents() {
  function onNativeEvent(name, payload) {
    if (name === "scope") handleScopePayload(payload);
  }
  if (WaxNative.hasBridge()) {
    window.WAX._internal.receiveWaxNativeEvent = onNativeEvent;
  }
  addEventListener("message", function (e) {
    if (e.data && e.data.type === "WAX_NativeEvent" && e.data.name === "scope") {
      handleScopePayload(e.data.payload);
    }
  });
}

// Step 1 — graph tap + render
function renderDevice() {
  const tone = wax.mul(params.gain, wax.cycle(params.frequency));
  const out = wax.scope({ name: SCOPE_NAME, size: SCOPE_SIZE, channels: 1 }, tone);
  wax.render(out, out);
}

// Step 3 — paint from scopeSamples
function drawScope() {
  requestAnimationFrame(drawScope);
  const canvas = document.getElementById("scope");
  if (!canvas) return;
  const ctx = canvas.getContext("2d");
  const w = canvas.width;
  const h = canvas.height;
  ctx.fillStyle = "#0f1419";
  ctx.fillRect(0, 0, w, h);
  if (!scopeSamples || !scopeSamples.length) return;
  ctx.strokeStyle = "#5eead4";
  ctx.lineWidth = 1;
  ctx.beginPath();
  for (let i = 0; i < scopeSamples.length; i++) {
    const x = (i / (scopeSamples.length - 1)) * w;
    const y = h * 0.5 - scopeSamples[i] * (h * 0.45);
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.stroke();
}

async function init() {
  wireScopeEvents(); // step 2 — before audio runs
  wax = await WaxNative.create();
  renderDevice();
  requestAnimationFrame(drawScope);
}

document.addEventListener("DOMContentLoaded", function () {
  const gainSlider = document.getElementById("gain");
  const freqSlider = document.getElementById("frequency");
  gainSlider.addEventListener("input", function () {
    params.gain = Number(gainSlider.value);
    renderDevice();
  });
  freqSlider.addEventListener("input", function () {
    params.frequency = Number(freqSlider.value);
    renderDevice();
  });
  init();
});
</script>

Try it — scoped oscillator waveform

Click Play — hook scope in the graph; canvas reads the latest block on each frame

10. Troubleshooting

Common issues

  • Sound only when editor open — Use adsr, midinotein, and engine sequencers — not JS timers for gates or envelopes.
  • No audio after load — On Project / hub URLs, poke renderDevice() and check wax.didEmit() (see below).
  • On-screen keyboard silent — await wax.midi.ready(), then noteOn / noteOff.
  • Preset wrong sound — Recall updated the slider but not params (or the other way around).
  • Sliders stutter while dragging — Debounce renderDevice() to about 16 ms on input; call it once on change.
  • Silent after session load — Session restored a hub URL, not your device page.

Project pages: poke & didEmit

When your device is opened through a hub or Project URL, WAX may reset the slot just after your script runs, which throws away your first commit — the page looks fine but is silent. On those URLs only, call WaxNative.keepAlive() once at the start of init() (keeps the page wired to the host) and add the helpers below.

async function init() {
  WaxNative.keepAlive();
  wax = await WaxNative.create();
  renderDevice();
  scheduleLoadPokes();
}

Add ensureEmit() and call it at the end of renderDevice():

function ensureEmit() {
  if (!wax || !wax.didEmit) return true;
  if (wax.didEmit()) return true;
  if (typeof wax.reemitLast === "function") wax.reemitLast("retry");
  return wax.didEmit();
}

At the end of renderDevice(), call ensureEmit(). Re-send the graph during the first seconds:

function poke() {
  WaxNative.keepAlive();
  renderDevice();
  if (!wax.didEmit()) {
    setTimeout(function () { renderDevice(); ensureEmit(); }, 40);
  }
}

function scheduleLoadPokes() {
  [50, 150, 400, 800, 1500, 2500, 4000].forEach(function (ms) {
    setTimeout(poke, ms);
  });
}

Before you ship

  • keepAlive(), pokes, and didEmit on Project pages; stable wax.const keys
  • Instruments: midinotein + wax.midi; sequencers: train / seq2 — not setInterval rebuilds
  • Test with editor closed, DAW MIDI, transport, session recall; unique DataTree appName

Remember: WaxNative runs your audio in WAX’s internal engine.


11. Reference — all wax.* nodes

Index of helpers and every graph node. Full API with parameters →

WaxNative helpers & page API

APIPurpose
WaxNative.create()One wax instance per page; Web Audio fallback in browser
WaxNative.hasBridge()true when the real plugin inject is present
WaxNative.keepAlive()Keep WebView wired on Project / hub URLs
wax.render(outL, outR?)Commit graph to the internal engine
wax.didEmit() / reemitLast()Confirm delivery; retry if first batch dropped
wax.host.sampleRate() …Input/output channels, block size for I/O layout
wax.midi.ready(), noteOn, noteOff, ccUI keyboard + host automation MIDI
wax.connectMic() / resumeAudio()Browser FX: mic and AudioContext unlock (no-op in plugin)
WaxNative.emitEvent("waxNativeRequestParamSync", {})Pull host CC values into sliders
WAX._internal.receiveWaxNativeEventScope/meter blocks from engine → page
window.WAX_Play / WAX_Stop / WAX_BPMDAW transport hooks → re-render graph

Graph nodes (wax.*)

Node index generated from @elemaudio/core@4.0.3 (bundled in wax-native.js). Same API in WAX and browser preview.

Host, time, and control

APIPurpose
wax.in({ channel: 0 })Host or browser input — FX entry point (`channel` index)
wax.const({ key: "gain", value: 0.5 })Control value with stable `key` — knobs, automation, presets
wax.param({ index: 1 })Host automation slot (`index` 1–32) as graph signal
wax.sr(…)Sample rate (Hz) as signal for graph math
wax.time(…)Elapsed time signal for modulation / sequencing

Oscillators and noise

APIPurpose
wax.cycle(440)Sine oscillator — frequency in Hz
wax.saw(440)Sawtooth oscillator
wax.square(440)Square wave oscillator
wax.triangle(440)Triangle wave oscillator
wax.train(440)Pulse / clock train at frequency
wax.phasor(…)0→1 ramp phasor at rate
wax.syncphasor(…)Hard-synced phasor
wax.blepsaw(…)Band-limited saw (cleaner bends)
wax.blepsquare(…)Band-limited square
wax.bleptriangle(…)Band-limited triangle
wax.noise(…)White noise source
wax.pinknoise(…)Pink noise source

Filters and tone

APIPurpose
wax.svf({ mode: "lowpass" }, fc, q, input)State-variable filter (LP/HP/BP/notch/allpass modes)
wax.svfshelf(…)SVF-based shelf EQ
wax.lowpass(…)One-pole lowpass
wax.highpass(…)One-pole highpass
wax.bandpass(…)Bandpass filter
wax.notch(…)Notch filter
wax.allpass(…)Allpass filter
wax.lowshelf(…)Low shelf EQ
wax.highshelf(…)High shelf EQ
wax.peak(…)Peaking EQ band
wax.biquad(…)Generic biquad coefficient filter
wax.pole(…)Real pole smoothing
wax.mm1p(…)Moog-style one-pole
wax.prewarp(…)Bilinear prewarp for cutoff
wax.smooth(…)Smooth a control signal
wax.sm(…)Smooth (alias)
wax.dcblock(…)Remove DC offset
wax.df11(…)First-order delay / filter building block
wax.zero(…)Delay / zero-pole helper
wax.pink(…)Pinking filter on white noise

Envelopes and dynamics

APIPurpose
wax.adsr(a, d, s, r, gate)Amplitude envelope from gate/trigger
wax.env(…)Simple envelope follower
wax.compress(…)Dynamics compressor
wax.skcompress(…)Sidechain-aware compressor

Delay, memory, and samples

APIPurpose
wax.delay(…)Delay line — time in seconds
wax.sdelay(…)Sample-based delay
wax.z(…)Single-sample delay (unit delay)
wax.tapIn(…)Write delay line tap
wax.tapOut(…)Read delay line tap
wax.sample(…)Play audio sample from buffer
wax.table(…)Wavetable / lookup oscillator
wax.convolve(…)Impulse response convolution reverb

Sequencing and logic

APIPurpose
wax.metro(…)Metronome pulse at interval
wax.seq(…)Step sequencer (legacy)
wax.seq2(…)Multi-output step sequencer
wax.sparseq(…)Sparse pattern sequencer
wax.sparseq2(…)Sparse sequencer v2
wax.sampleseq(…)Sample-triggering sequencer
wax.sampleseq2(…)Sample sequencer v2
wax.counter(…)Increment while gate high
wax.accum(…)Accumulate while gate high
wax.latch(…)Hold value while gate high (note pitch memory)
wax.once(…)Fire once when trigger rises
wax.maxhold(…)Hold maximum of input
wax.rand(…)Random value on trigger

MIDI in the graph

APIPurpose
wax.midinotein()Merged host + UI MIDI note stream
wax.midinoteallocate({ voices: 8 }, midiIn)Assign note-ons to polyphony voices
wax.midinoteunpack({ channel: 0 }, voices)Per-voice pitch and velocity
wax.midinoteshift(…)Transpose MIDI note numbers

Multichannel (wax.mc)

APIPurpose
wax.mc.sample(…)Multichannel sample player
wax.mc.table(…)Multichannel wavetable
wax.mc.sampleseq(…)Multichannel sample sequence
wax.mc.sampleseq2(…)Multichannel sample sequence v2
wax.mc.capture(…)Multichannel capture buffer

Analysis and metering

APIPurpose
wax.scope({ name: "main", size: 512, channels: 1 }, signal)Send audio blocks to page for meters/waveforms
wax.meter(…)Level metering tap
wax.fft(…)FFT analysis tap
wax.snapshot(…)Capture graph state snapshot
wax.capture(…)Record signal to buffer

Signal utilities

APIPurpose
wax.select(…)Switch between inputs by index
wax.ms2samps(…)Convert milliseconds to samples
wax.tau2pole(…)Time constant → filter pole
wax.db2gain(…)Decibels to linear gain
wax.gain2db(…)Linear gain to decibels
wax.hann(…)Hann window for overlap-add

Math and comparisons

APIPurpose
wax.add(…)Sum signals
wax.sub(…)Subtract signals
wax.mul(…)Multiply / gain
wax.div(…)Divide signals
wax.mod(…)Modulo
wax.min(…)Minimum of inputs
wax.max(…)Maximum of inputs
wax.pow(…)Power
wax.sin(…)Sine math
wax.cos(…)Cosine math
wax.tan(…)Tangent math
wax.tanh(…)Tanh soft clip
wax.abs(…)Absolute value
wax.sqrt(…)Square root
wax.exp(…)Exponential
wax.ln(…)Natural log
wax.log(…)Log
wax.log2(…)Log base 2
wax.floor(…)Round down
wax.ceil(…)Round up
wax.round(…)Round to nearest
wax.asinh(…)Inverse hyperbolic sine
wax.le(…)Less than or equal (logic)
wax.leq(…)Less or equal
wax.ge(…)Greater than or equal — common for MIDI gates
wax.geq(…)Greater or equal
wax.eq(…)Equal comparison
wax.and(…)Logical AND
wax.or(…)Logical OR

Other bundled exports

APIPurpose
wax.constant(…)Graph node (other) — see @elemaudio/core
wax.identity(…)Graph node (other) — see @elemaudio/core