Build a Browser Mixer
A mixing-only web app needs file loading, synchronized playback, track controls, and a rendered download. libsonare supplies the DSP and routing; the app owns the interface, audio storage, and WAV encoding. MIDI editing, instruments, and recording can be left out.
The Mixing Studio is a working example: load local stems, place and fade clips, adjust trim, a six-band EQ, gate, compressor, de-esser, ducking, pan, width, sends to a plate reverb and a delay, routing each track to the master or one of two subgroup buses, and a master glue compressor and limiter, then bounce and download a stereo WAV. Start with Load demo, listen with Play, change a fader, and use Bounce WAV. The app processes audio locally. The three lanes below are the engine's own channel strips, the controls the studio is built from.
Choose the playback and rendering API
| App behavior | Entry point | Host responsibilities |
|---|---|---|
| Mix supplied stereo arrays once | mixStereo | Decode, align and pad inputs, then encode the result |
| Run a focused stem mixer with a custom playback bridge | Mixer | Feed blocks, manage transport, communicate UI changes, and render the same graph offline |
| Keep timeline playback, controls, automation, and offline rendering together | SonareEngine from @libraz/libsonare/worklet | Load clips, declare lanes and routing, connect the output, and provide the UI and persistence |
For a new app with interactive playback and export, start from the SonareEngine setup and control table. The facade mirrors controls to an AudioWorklet engine and an offline engine. Its renderOffline result is PCM, and bounceOffline returns an interleaved buffer with the raw engine's options (see Offline render, bounce, and freeze); encoding and downloading a WAV remain app work. Long offline jobs need a worker strategy: an async method alone does not move computation off the main thread.
Mixing Studio uses the third row. The facade plays the project live, and a worker bounces the same project on a plain RealtimeEngine. Continuous controls reach the engine through setters, so a fader move during playback keeps processor state such as reverb history. The few construction-only parameters, such as a gate's hold time, rebuild the strip's inserts, so the page applies them when the control is released. A track's output, the master or a subgroup bus, is set with setTrackOutputBus as one more operation in the same list, so the preview and the bounce route identically.
Render a mix in one call
What you need
- Install:
npm install @libraz/libsonarein a web project with a bundler that serves ES modules and.wasmfiles, such as Vite. The WebAssembly guide covers loading the package. - A file picker:
<input type="file" accept="audio/*" multiple>. Itsfileslist is theFile[]the code below reads. - A user gesture: create or resume the
AudioContextfrom a click handler. Browsers keep anAudioContextcreated without one suspended, and you need it running to play the result. - Stems: WAV or MP3 exported from one session, so they start together.
The smallest working mixer is mixStereo: decode the stems, pad them to one length, mix, and encode the result. Fader, pan, trim and width take one value per stem; mix.meters[i] holds each stem's meter for the block. files is the File[] from your picker, and the zeros stand in for values from your UI.
import { init, mixStereo } from '@libraz/libsonare';
await init();
const ctx = new AudioContext();
// Decode at the context rate, then pad every stem to the longest one.
const buffers = await Promise.all(
files.map(async (f) => ctx.decodeAudioData(await f.arrayBuffer())),
);
const length = Math.max(...buffers.map((b) => b.length));
const channel = (b: AudioBuffer, i: number) => {
const out = new Float32Array(length);
out.set(b.getChannelData(Math.min(i, b.numberOfChannels - 1))); // mono feeds both sides
return out;
};
const mix = mixStereo(
buffers.map((b) => channel(b, 0)),
buffers.map((b) => channel(b, 1)),
ctx.sampleRate,
{ faderDb: buffers.map(() => 0), pan: buffers.map(() => 0) },
);
// 16-bit stereo WAV.
function encodeWav(left: Float32Array, right: Float32Array, sampleRate: number): Blob {
const n = left.length;
const view = new DataView(new ArrayBuffer(44 + n * 4));
const tag = (o: number, s: string) => [...s].forEach((c, i) => view.setUint8(o + i, c.charCodeAt(0)));
tag(0, 'RIFF'); view.setUint32(4, 36 + n * 4, true); tag(8, 'WAVEfmt ');
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 2, true);
view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 4, true);
view.setUint16(32, 4, true); view.setUint16(34, 16, true);
tag(36, 'data'); view.setUint32(40, n * 4, true);
const s16 = (x: number) => Math.round(Math.max(-1, Math.min(1, x)) * 32767);
for (let i = 0; i < n; i++) {
view.setInt16(44 + i * 4, s16(left[i]), true);
view.setInt16(46 + i * 4, s16(right[i]), true);
}
return new Blob([view.buffer], { type: 'audio/wav' });
}
// The sum is not limited: stems at 0 dB can add up past full scale, and the
// encoder above would then clamp them flat. Check the peak before encoding.
let peak = 0;
for (const ch of [mix.left, mix.right]) for (const x of ch) peak = Math.max(peak, Math.abs(x));
if (peak > 1) console.warn(`mix peaks at ${(20 * Math.log10(peak)).toFixed(1)} dBFS; lower the faders`);
const url = URL.createObjectURL(encodeWav(mix.left, mix.right, mix.sampleRate));If the warning fires, lower the faders (or apply one gain to both channels) and mix again; three stems at 0 dB summing in phase can reach three times full scale. A sample peak under full scale still leaves inter-sample peaks to check before the file is delivered.
The rest of this page is what an app adds around that call: live playback, edits that stay in sync with the export, and project files.
Keep audio, project state, and engine operations separate
- Decode all files with one
AudioContext. A mono input is copied to both sides. A 5.1 or 7.1 file is folded to stereo with the engine'sdownmix(centre and surrounds at −3 dB, LFE dropped), and any other channel count above two is folded to mono, so an app that keeps surround channels separate needs its own channel mapping. The demo keeps both channels at the context's sample rate: a mono analysis buffer cannot replace stereo playback audio. - Keep PCM arrays in an audio registry keyed by track id, outside the project state. A clip has a start position, a trim into the source, a length, and fades. Imported stems start together at zero; moving a clip is an explicit timeline edit.
- Keep the project as one plain, serializable object: tracks, buses, master, VCA gains, and loop. Every edit is a pure function from one state to the next, the difference between two states becomes a list of engine operations, and undo and redo take the same path. The mixer scene format describes a standalone
Mixergraph; the demo drives the realtime engine directly and does not use it. - Save the project JSON separately. It holds the state without audio; a reopened project lists its tracks as waiting for audio, and each file added attaches to the track with the same name, otherwise to the next one in order.
- Render PCM and encode a WAV for download. The demo writes 16-bit, 24-bit, or 32-bit float WAV, with TPDF or noise-shaped dither for the integer formats. Check peak and true peak after bouncing. If levels need lowering, adjust them and bounce again before downloading.
An app that reopens complete sessions needs its own audio persistence, such as stored files plus stable source identifiers. Neither mixer scene JSON nor the demo's project file is a complete audio project archive.
Read the reference implementation
The homepage source directory contains the demo. Read these files in order:
| File | Responsibility |
|---|---|
useMixingStudio.ts | File loading and the audio budget, history, engine sync, auto-mix A/B, Worker jobs, and project files |
mixingState.ts | The project state, its defaults, the mutators, and v2 parsing |
mixingEngine.ts | State to engine operations: a full build, a diff, and the export length |
engineApply.ts | The operation set and the two adapters, one over the live facade and one over the raw engine |
useSonareEngine.ts | Booting the SonareEngine worklet facade, transport position, meters, and teardown |
mixingBounce.ts | Worker lifecycle, request routing, and PCM buffer transfers |
mixing.worker.ts | The bounce, auto-mix analysis, and the loudness measurement behind the A/B |
mixAssistantMap.ts | Mapping a mix suggestion onto the fixed strip layout |
mixingProjectV1.ts | Converting a v1 project file |
audio.ts | Decoding helpers, waveform summaries, and WAV encoding with format and dither |
MixingStudio.vue | Arrangement, console, and master section |
The demo imports copied engine assets through @/wasm. In a separate app, use the full @libraz/libsonare package; the smaller analysis entry excludes mixing. Package loading is covered by the WebAssembly guide, and custom worklet initialization by WASM Streaming and Realtime.
Understand what the preview verifies
| Behavior | Mixing Studio |
|---|---|
| Track controls, EQ, dynamics, sends, VCA | Applied live through setters while playing |
| Clip moves, trims, fades, and automation lanes | Play live and in the bounce |
| Strip, bus, and master meters | Live; the loudness range is read from the last bounce |
| Channel delay | Applied only while stopped; an edit made during playback waits for the transport to stop |
| Auto-mix A/B | Switches the live mix between yours and the suggestion, loudness-matched at the monitor stage only; the louder side is turned down, and the match never reaches the bounce or the project |
| Preview and bounce | Render bit-exact from the same state |
| Effect tails and graph latency | The bounce runs to the last clip's end plus the engine's reported graph latency (graphLatencySamplesQ8) and tail (tailSamples()) |
| Project file | v2 holds the state without audio; a v1 file is converted when opened |
The worker receives the track PCM transferred from the registry, so while a bounce, an analysis, or a loudness measurement runs the arrays are detached and editing is locked; they return to the registry when the job ends. Each source is held in the page, in the engine, and in the bounce worker, which raises peak memory use.
The eight-track, five-minute, and twenty-minute-in-total limits are demo budgets, not engine limits. Raising them requires measuring memory and audio-thread load on target devices. For longer sessions, see paged clip audio streaming; OPFS paging into the worklet engine works with or without SharedArrayBuffer and cross-origin isolation, and the demo's playback runs without them.
Verify an app before using it for a mix
- Load stems exported from one session and confirm their start positions remain aligned.
- Bounce the same state you auditioned and compare it with the live preview, including automation and the reverb tail.
- Test mono and stereo inputs, differing file sample rates, mute/solo, clip offsets, fades, and a reverb tail at the end.
- Save the project, reopen the matching audio, and open the project file. Confirm that each track takes its audio and the controls are restored.
- Stop playback and unmount the app while loading or bouncing. Release the engine, workers, audio contexts, and download URLs.
Related
- Mixing Engine — DSP controls, scene processing, automation, and meters
- Realtime Engine — timeline, routing, and offline rendering
- Mixing Demo Project JSON — the project file used by this demo
- Mixing Scene JSON — the engine graph format