Skip to content

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.

ENGINE · LANE MIXERIDLE
The engine lane mixer — faders and mutes inside the playback engine

Three MIDI clips loop through the realtime engine: each track occupies a lane with its own channel strip, and the lanes sum into a master strip carrying the engine’s true-peak limiter. The faders call the strip setters and the mutes call setSoloMute — every band below is the engine’s separate solo previews through the master chain, re-rendered through renderOffline as you move the controls.

Lead fader
0 dB
Bass fader
3 dB
Drums fader
-8 dB
Mute lead
Mute bass
Mute drums

Choose the playback and rendering API ​

App behaviorEntry pointHost responsibilities
Mix supplied stereo arrays oncemixStereoDecode, align and pad inputs, then encode the result
Run a focused stem mixer with a custom playback bridgeMixerFeed blocks, manage transport, communicate UI changes, and render the same graph offline
Keep timeline playback, controls, automation, and offline rendering togetherSonareEngine from @libraz/libsonare/workletLoad 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/libsonare in a web project with a bundler that serves ES modules and .wasm files, such as Vite. The WebAssembly guide covers loading the package.
  • A file picker: <input type="file" accept="audio/*" multiple>. Its files list is the File[] the code below reads.
  • A user gesture: create or resume the AudioContext from a click handler. Browsers keep an AudioContext created 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.

typescript
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 ​

One state, one op list, two engines
prev → nextcreateFacadeTargetcreateRawTargetProject statemixingEngine.ts diffEngineOp[]SonareEngine(AudioWorklet)RealtimeEngine(Worker)Live previewBounced PCM
engineApply.ts interprets the same operations over the live facade and over the export Worker's raw engine, so the preview and the bounce are one render.
  1. 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's downmix (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.
  2. 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.
  3. 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 Mixer graph; the demo drives the realtime engine directly and does not use it.
  4. 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.
  5. 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:

FileResponsibility
useMixingStudio.tsFile loading and the audio budget, history, engine sync, auto-mix A/B, Worker jobs, and project files
mixingState.tsThe project state, its defaults, the mutators, and v2 parsing
mixingEngine.tsState to engine operations: a full build, a diff, and the export length
engineApply.tsThe operation set and the two adapters, one over the live facade and one over the raw engine
useSonareEngine.tsBooting the SonareEngine worklet facade, transport position, meters, and teardown
mixingBounce.tsWorker lifecycle, request routing, and PCM buffer transfers
mixing.worker.tsThe bounce, auto-mix analysis, and the loudness measurement behind the A/B
mixAssistantMap.tsMapping a mix suggestion onto the fixed strip layout
mixingProjectV1.tsConverting a v1 project file
audio.tsDecoding helpers, waveform summaries, and WAV encoding with format and dither
MixingStudio.vueArrangement, 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 ​

BehaviorMixing Studio
Track controls, EQ, dynamics, sends, VCAApplied live through setters while playing
Clip moves, trims, fades, and automation lanesPlay live and in the bounce
Strip, bus, and master metersLive; the loudness range is read from the last bounce
Channel delayApplied only while stopped; an edit made during playback waits for the transport to stop
Auto-mix A/BSwitches 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 bounceRender bit-exact from the same state
Effect tails and graph latencyThe bounce runs to the last clip's end plus the engine's reported graph latency (graphLatencySamplesQ8) and tail (tailSamples())
Project filev2 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.