Dev Helper Doc
Canonical Markdown: WEB.md
Overview
Using standard HTML, CSS, JavaScript, Web Audio, and Web MIDI APIs, developers can build instruments, effects, sequencers, visualizers, educational tools, and interactive audio experiences directly inside professional production environments.
At the center of this workflow is WaxWeb.js — a lightweight helper that simplifies transport synchronization, MIDI handling, scheduling, playhead access, automation, and state management.
Helper version: 0.2.1
Requires: WAX runtime ≥ 1.0.0, WAX plugin ≥ 1.20.0
No special SDK is required.
No plugin framework experience is required.
If you can build a website, you can build a WAX app.
New to WAX? Start here. For raw global APIs (window.WAX_Play, window.PlayheadInfo, etc.), see WAX Developer Documentation.
Getting Started
Include WaxWeb.js at the top of the script.
<script src="wax-web.js"></script>
Create a WAX instance:
const wax = WaxWeb.create({
appName: "my-first-app"
});
appName is required. Use a stable, unique string per page. It identifies your app’s DataTree storage and must not change after release.
This gives you access to:
wax.audio
wax.host
wax.midi
wax.playhead
wax.transport
wax.scheduler
wax.data
Check that you are running inside WAX:
if (wax.isWax()) {
console.log("Running inside WAX", wax.version);
}
Audio
Use the Web Audio API exactly as you normally would.
Inside WAX, audio is already activated by the host.
You do not need “Click to Start Audio” buttons.
The AudioContext runs tied to the DAW’s sample rate and processing block size.
Output (Instruments & Effects)
const wax = WaxWeb.create({ appName: "my-synth" });
const ctx = wax.audio.context();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
wax.audio.connect(gain, ctx); // connect to plugin output
gain.gain.value = 0.1;
osc.start();
Or connect directly to the destination:
gain.connect(wax.audio.output(ctx));
wax.audio.context() returns a shared AudioContext by default. Pass { shared: false } to create a separate one.
Input (Effects)
To receive audio from the DAW:
const wax = WaxWeb.create({ appName: "my-effect" });
const ctx = wax.audio.context();
wax.audio.input(ctx).then((input) => {
input.connect(wax.audio.output(ctx));
});
wax.audio.input(ctx) requests the audio input and returns a ready-to-connect MediaStreamAudioSourceNode.
Inside WAX, getUserMedia({ audio: true }) represents the plugin input path. In a normal browser, this is regular microphone input.
For advanced cases, the raw stream is still available:
const stream = await wax.audio.inputStream();
const tracks = stream.getAudioTracks();
Host
Read host audio configuration for routing and UI labels.
const inCh = wax.host.inputChannels(); // e.g. 1 or 2
const outCh = wax.host.outputChannels(); // e.g. 1 or 2
const sr = wax.host.sampleRate(); // e.g. 48000
const block = wax.host.blockSize(); // e.g. 512 or 1024
Use these when building stereo/mono routing, channel meters, or host info displays.
MIDI
WAX uses the standard Web MIDI API.
MIDI input comes from the DAW track.
MIDI output is sent back into the DAW.
Initialize MIDI
const wax = WaxWeb.create({ appName: "my-midi-app" });
wax.midi.ready().then(() => {
console.log("MIDI Ready");
});
Receive MIDI
wax.midi.onMessage((msg) => {
console.log(msg.type);
console.log(msg.note);
console.log(msg.velocity);
});
Example message types:
- noteon
- noteoff
- cc
- pitchbend
- programchange
Each message also includes channel, controller, value, and raw bytes.
onMessage() returns an unsubscribe function.
Send MIDI
wax.midi.noteOn(60, 127); // note, velocity
wax.midi.noteOff(60); // note
wax.midi.cc(1, 64); // controller, value
Optional MIDI channel (1–16):
wax.midi.noteOn(60, 100, 1);
wax.midi.cc(1, 64, 1);
Raw bytes are still available:
wax.midi.send([0x90, 60, 100]);
Transport
Respond to DAW playback and BPM changes.
The helper lets multiple modules register handlers without overwriting each other. Existing window.WAX_Play, window.WAX_Stop, and window.WAX_BPM handlers are preserved and chained.
Playback Events
const wax = WaxWeb.create({ appName: "my-app" });
const unsubPlay = wax.transport.onPlay(() => {
console.log("DAW Started");
});
const unsubStop = wax.transport.onStop(() => {
console.log("DAW Stopped");
});
Each handler returns an unsubscribe function.
BPM Updates
wax.transport.onBpm((bpm) => {
console.log("Host BPM:", bpm);
});
Playhead
The playhead provides access to:
- transport position
- PPQ timing
- tempo
- looping
- time signature
- playback state
This is the timing authority for DAW synchronization.
Start Playhead Updates
const wax = WaxWeb.create({ appName: "my-app" });
wax.playhead.start(8); // 8 = update interval in milliseconds
Stop updates when done:
wax.playhead.stop();
Read Timing
const timing = wax.playhead.getTiming();
console.log(timing.ppq);
console.log(timing.bpm);
console.log(timing.isPlaying);
getTiming() returns a flattened view for convenience. ppq is the same value as timing.ppqPosition on the raw PlayheadInfo object returned by request() or window.PlayheadInfo.
Useful Timing Values
timing.ppq
timing.ppqBarRelative
timing.ppqExtrapolated
timing.timeInSeconds
timing.timeInSamples
timing.bpm
timing.timeSigNumerator
timing.timeSigDenominator
ppqExtrapolated and ppqBarRelative advance between host snapshots so UI and sequencers stay smooth while the DAW is playing.
Disable extrapolation:
wax.playhead.getTiming({ extrapolate: false });
Individual Accessors
wax.playhead.isPlaying()
wax.playhead.isRecording()
wax.playhead.isLooping()
wax.playhead.bpm()
wax.playhead.ppq()
wax.playhead.ppqBarStart()
wax.playhead.ppqBarRelative()
wax.playhead.timeInSeconds()
wax.playhead.timeInSamples()
wax.playhead.timeSig() // { numerator, denominator }
wax.playhead.loop() // { ppqStart, ppqEnd, isLooping }
wax.playhead.stepIndex(4, 16) // 16th-note step index 0–15
Subscribe to Updates
const unsub = wax.playhead.subscribe(() => {
const t = wax.playhead.getTiming();
console.log(t.isPlaying, t.bpm, t.ppqExtrapolated);
});
One-Shot Request
const info = await wax.playhead.request();
console.log(info.timing.ppqPosition);
PlayheadInfo Shape
The object returned by request() (and window.PlayheadInfo) uses this structure:
info.state.isPlaying // boolean — transport playing
info.state.isRecording // boolean — transport recording
info.state.isLooping // boolean — loop enabled
info.tempo.bpm // number — host tempo (e.g. 120)
info.tempo.timeSigNumerator
info.tempo.timeSigDenominator
info.timing.timeInSamples
info.timing.timeInSeconds
info.timing.ppqPosition
info.timing.ppqPositionOfLastBarStart
info.loop.ppqLoopStart
info.loop.ppqLoopEnd
info.loop.isLooping
Scheduling
JavaScript timers are not reliable for audio playback.
Functions like:
setInterval()
requestAnimationFrame()
can stall when:
- the editor closes
- the tab is hidden
- the UI thread slows down
Professional timing must happen on the audio thread.
Correct Scheduling
Instead of:
setInterval(() => {
playSound();
}, 125);
Schedule directly on the Web Audio timeline:
const when = audioContext.currentTime + 0.05;
source.start(when);
The audio engine guarantees accurate playback.
Tempo-Based Scheduling
WAX provides a scheduler system for DAW-synced sequencing.
Step Sequencer Example
const wax = WaxWeb.create({ appName: "my-sequencer" });
const ctx = wax.audio.context();
const scheduler = wax.scheduler.createStepScheduler({
audioContext: ctx,
steps: 16,
stepsPerQuarter: 4,
lookaheadMs: 25,
scheduleAheadSec: 0.1,
onStep(step, whenSec, info) {
playStep(step, whenSec);
}
});
scheduler.start();
When the DAW is playing, the scheduler follows host PlayheadInfo with PPQ extrapolation between host snapshots.
When the DAW is not playing, it falls back to a local BPM clock so you can still preview.
For bar-aligned grids (16 steps per bar):
barRelative: true
Scheduler controls:
scheduler.stop();
scheduler.reset();
scheduler.setBpm(128);
scheduler.isRunning();
scheduler.dispose();
Understanding PPQ
PPQ = Pulses Per Quarter Note.
This is the most important timing system in DAWs.
Examples:
ppq = 0 // beginning of song
ppq = 1 // one quarter note later
ppq = 2.5 // halfway through beat 3
Converting PPQ to Steps
16th-note sequencer:
step = Math.floor(ppq * 4) % 16;
Or use the helper:
wax.playhead.stepIndex(4, 16);
DataTree
DataTree handles persistent state.
Use it for:
- presets
- sequencer patterns
- UI state
- project recall
- saved parameters
Save State
const wax = WaxWeb.create({
appName: "my-synth"
});
wax.data.push({
cutoff: 1200,
resonance: 0.4,
waveform: "saw"
});
Load State
wax.data.pull().then((data) => {
console.log(data);
});
Hydration Events
wax.data.onHydrated((data) => {
console.log("Restored", data);
});
Cached State
const cached = wax.data.cached();
Provider Hook
wax.data.setProvider(() => {
return getCurrentState();
});
Best Practice
Always attempt a pull before pushing data.
Incorrect:
wax.data.push(defaultState);
on startup can overwrite the user’s saved preset.
Correct flow:
- pull existing state
- apply restored values
- only push after user changes
Pick one stable appName per page and never change it after release.
Author API only: use wax.data.push, pull, cached, onHydrated, and setProvider. Do not invent a host backend, ValueTree sync layer, or call low-level bridge APIs for presets.
Required (do not skip): setProvider + await wax.data.pull() on boot, then push only after pull settles. onHydrated alone is not enough. Full copy-paste boot block: https://szfpro.github.io/CodeEditorHTML/wax/docs/WEB.md.
Wrong vs right
- Wrong:
wax.data.push("cutoff", 1200)→ Right:wax.data.push({ schema: 1, params: { cutoff: 1200 } }) - Wrong:
await wax.data.pull("cutoff")→ Right:await wax.data.pull()(appNamealready set oncreate) - Wrong:
wax.data.subscribe(...)→ Right: that API does not exist — use pull / onHydrated / setProvider - Wrong: inventing
<script src>paths under this docs site → Right:https://szfpro.github.io/CodeEditorHTML/wax-web.js(or plugin inject)
Embed Audio In DataTree
DataTree stores one JSON object per appName. It is not a filesystem. There is no .wav path and no wax.files API.
If the same knobs always rebuild the same sound, save parameters only.
If recall must restore the same samples (random generation, a recording, a user import, a long convolution IR), put the audio inside that JSON:
- Encode an
AudioBufferas 16-bit PCM WAV. - Base64 the bytes into a string (
payloadB64). - Keep small metadata (
format,sampleRate,channels,byteLength). - Cache that string. Do not re-encode every time a knob moves.
Snapshot shape
{
schema: 1,
params: { mix: 40, decay: 8 },
payloadB64: "UklGRi…",
payloadMeta: {
format: "wav",
sampleRate: 48000,
channels: 2,
byteLength: 192044
}
}
Encode AudioBuffer → WAV → Base64
Run this once when the buffer is finalized (after record, import, or generation). Store the result on your app object and reuse it in setProvider.
function audioBufferToWav16(buffer) {
const numChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const numFrames = buffer.length;
const bytesPerSample = 2;
const blockAlign = numChannels * bytesPerSample;
const dataSize = numFrames * blockAlign;
const bufferLength = 44 + dataSize;
const arrayBuffer = new ArrayBuffer(bufferLength);
const view = new DataView(arrayBuffer);
let offset = 0;
function writeString(s) {
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
offset += s.length;
}
function writeUint32(v) {
view.setUint32(offset, v, true);
offset += 4;
}
function writeUint16(v) {
view.setUint16(offset, v, true);
offset += 2;
}
writeString("RIFF");
writeUint32(36 + dataSize);
writeString("WAVE");
writeString("fmt ");
writeUint32(16);
writeUint16(1);
writeUint16(numChannels);
writeUint32(sampleRate);
writeUint32(sampleRate * blockAlign);
writeUint16(blockAlign);
writeUint16(16);
writeString("data");
writeUint32(dataSize);
const channels = [];
for (let ch = 0; ch < numChannels; ch++) {
channels.push(buffer.getChannelData(ch));
}
for (let i = 0; i < numFrames; i++) {
for (let ch = 0; ch < numChannels; ch++) {
const s = Math.max(-1, Math.min(1, channels[ch][i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
offset += 2;
}
}
return new Uint8Array(arrayBuffer);
}
function bytesToBase64(bytes) {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + chunk)
);
}
return btoa(binary);
}
function capturePayloadFromBuffer(buffer) {
const wavBytes = audioBufferToWav16(buffer);
return {
payloadB64: bytesToBase64(wavBytes),
payloadMeta: {
format: "wav",
sampleRate: buffer.sampleRate,
channels: buffer.numberOfChannels,
byteLength: wavBytes.byteLength,
},
};
}
State object + cached payload
const wax = WaxWeb.create({ appName: "my-granular" });
let params = { mix: 40, decay: 8 };
let cachedPayload = null; // { payloadB64, payloadMeta } or null
let restoredFromDataTree = false;
function collectState() {
return {
schema: 1,
params: { ...params },
...(cachedPayload || {}),
};
}
wax.data.setProvider(() => collectState());
When to push
- Knobs / presets: small payload (no WAV). Debounce pushes so you are not spamming the host.
- After the WAV is encoded: set
cachedPayload, then push (or rely on the next host save if your provider already includes it). - On host save:
setProvidermust return the full snapshot, includingpayloadB64when present.
let pushTimer = null;
function schedulePush() {
clearTimeout(pushTimer);
pushTimer = setTimeout(() => {
wax.data.push(collectState());
}, 300);
}
function onKnobChange(nextParams) {
params = { ...params, ...nextParams };
schedulePush(); // params only — cachedPayload unchanged
}
function onBufferFinalized(audioBuffer) {
cachedPayload = capturePayloadFromBuffer(audioBuffer);
wax.data.push(collectState()); // include WAV once
}
Recall: params first, decode audio after AudioContext exists
Apply knob values immediately. Decode payloadB64 with decodeAudioData only after wax.audio.context() is available. If decode succeeds, set a flag so your boot logic does not auto-regenerate and overwrite the saved buffer.
function base64ToArrayBuffer(b64) {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
async function applyRestoredState(raw) {
if (!raw || raw.schema !== 1) return;
params = { ...params, ...(raw.params || {}) };
applyParamsToEngine(params); // your UI + DSP
if (!raw.payloadB64 || !raw.payloadMeta) return;
const ctx = wax.audio.context();
const ab = base64ToArrayBuffer(raw.payloadB64);
try {
const decoded = await ctx.decodeAudioData(ab.slice(0));
setEngineSampleBuffer(decoded); // your playback node
restoredFromDataTree = true;
} catch (err) {
console.warn("DataTree audio decode failed", err);
}
}
async function bootDataTree() {
wax.data.setProvider(() => collectState());
const raw = await wax.data.pull();
if (raw) {
await applyRestoredState(raw);
} else if (!restoredFromDataTree) {
generateDefaultBuffer(); // only when nothing was saved
}
}
bootDataTree();
Size limits
- Base64 is about 33% larger than the raw WAV.
- At 48 kHz stereo 16-bit that is roughly 0.2 MB per second of audio (~0.27 MB/s in JSON).
- Cap duration. Prefer mono for long material.
- Huge clips bloat the DAW preset and slow session save/load.
Automation
WAX automation is MIDI-based.
The recommended workflow:
- send CC when user moves controls
- receive CC from host automation
- update UI + audio engine together
Avoiding Feedback Loops
Only send MIDI when the change originated from the user.
Do not retransmit incoming automation back to the host.
Example
let fromMIDI = false;
slider.addEventListener("input", () => {
if (!fromMIDI) {
wax.midi.cc(1, slider.value);
}
});
wax.midi.onMessage((msg) => {
if (msg.type === "cc") {
fromMIDI = true;
slider.value = msg.value;
fromMIDI = false;
}
});
Background Execution
Audio scheduling should never depend entirely on UI timing.
The UI thread may:
- slow down
- sleep
- pause
- stall
The audio thread continues independently.
Use:
- AudioContext.currentTime
- scheduled playback
- lookahead scheduling
- transport synchronization
instead of relying on visual timers alone.
Quick Reference
Create App
const wax = WaxWeb.create({
appName: "my-app"
});
wax.isWax()
wax.version
Audio
wax.audio.context()
wax.audio.input()
wax.audio.inputStream()
wax.audio.output()
wax.audio.connect(node, ctx)
Host
wax.host.inputChannels()
wax.host.outputChannels()
wax.host.sampleRate()
wax.host.blockSize()
MIDI
wax.midi.ready()
wax.midi.onMessage()
wax.midi.send()
wax.midi.noteOn()
wax.midi.noteOff()
wax.midi.cc()
Transport
wax.transport.onPlay()
wax.transport.onStop()
wax.transport.onBpm()
Playhead
wax.playhead.start()
wax.playhead.stop()
wax.playhead.request()
wax.playhead.get()
wax.playhead.subscribe()
wax.playhead.getTiming()
wax.playhead.ppq()
wax.playhead.bpm()
wax.playhead.isPlaying()
wax.playhead.stepIndex(stepsPerQuarter, steps)
Scheduler
wax.scheduler.createStepScheduler({
audioContext,
steps,
stepsPerQuarter,
barRelative,
onStep(step, whenSec, info)
})
DataTree
wax.data.push()
wax.data.pull()
wax.data.cached()
wax.data.onHydrated()
wax.data.setProvider()
Raw APIs Still Work
The helper is additive. Existing pages can continue using:
new AudioContext();
navigator.mediaDevices.getUserMedia({ audio: true });
navigator.requestMIDIAccess();
window.WAX_DataTree.push(data, appName);
window.WAX_Play / window.WAX_Stop / window.WAX_BPM;
window.WAX_RequestPlayheadInfo();
window.PlayheadInfo;
Use WaxWeb when you want common WAX app patterns to be shorter, consistent, and easier to teach. For full raw API details, see WAX Developer Documentation.
Final Notes
WAX allows web applications to become deeply integrated audio tools inside professional production environments.
By combining modern browser technologies with DAW synchronization, transport awareness, MIDI routing, scheduling, and persistent state management, WAX dramatically lowers the barrier to creating powerful music software.
The web is no longer separate from audio production.
With WAX, the browser becomes part of the studio.