Mixing Engine
Mixing is the step where many separate tracks are balanced and combined into one stereo result. Those tracks might be a vocal, a bass, a drum kit, and a music bed.
libsonare includes a real-time-safe mixing and routing engine on top of the same C++ DSP core that powers the mastering processors. That means you can build a small mixer inside your own app; no DAW host is required.
If the words strip, bus, send, fader, pan, or automation are new to you, read Mixing Basics first — this page assumes you know what they mean and focuses on how libsonare implements them and what to call to get a job done.
Where mixing sits in the pipeline
| Stage | Role |
|---|---|
| Analysis | Tells you what a track is |
| Editing | Fixes timing and pitch of one track |
| Mixing | Balances several tracks into a stereo bus |
| Mastering | Polishes the finished stereo mix for delivery |
Mixing is the stage that turns "a folder of stems" — the individual instrument tracks or submixes exported from a session — into "a song". You usually mix first, then master the result.
Three words to know first
A strip is one track's processing lane. A bus is a shared destination for one or more tracks. A send copies some of a track to another bus while the original path continues. These three ideas make faders, panning, reverb sends, and meters much easier to follow.
Inserts vs sends
These are the two ways a strip uses a processor, and they answer different questions:
| Insert | Send | |
|---|---|---|
| Signal path | In series — the whole signal passes through | In parallel — a copy is routed to a bus |
| Typical use | Compressor, EQ, de-esser on the track itself | Shared reverb/delay several tracks feed |
| Wet/dry | The processor's own mix controls it | Dry stays on the strip; only the send copy is processed |
| Where it sits | In a pre-fader or a post-fader slot | Tapped pre- or post-fader, your choice |
A reverb you want on one vocal can be an insert. A reverb you want shared across the vocal, the snare, and the guitar should be a send into an aux bus with a reverb on its return — one reverb instance, many sources, consistent space.
Decibels, gain, and summing
Faders, trims, and sends are set in dB. A gain of g dB multiplies the samples by 10^(g/20):
| dB | Linear gain | What it means |
|---|---|---|
| 0 dB | 1.0 | Unchanged |
| −3 dB | 0.707 | Half the power |
| −6 dB | 0.5 | Half the amplitude |
| −12 dB | 0.25 | A quarter of the amplitude |
| −∞ dB | 0 | Silence |
Summing two equal signals depends on how alike they are. Two identical (coherent) copies add up to +6 dB, because the amplitudes add. Two unrelated signals of equal level add up to about +3 dB, because only their power adds. That difference is why the pan laws below differ. See Audio Basics for dBFS, headroom, and latency.
What You Will Learn
By the end of this page you should be able to:
- pick
mixStereofor one-shot stem rendering orMixer.fromSceneJsonfor persistent routing; - follow the signal flow through trim, polarity, delay, EQ, inserts, fader, pan, width, sends, and meters;
- understand pre-fader vs post-fader sends well enough to avoid the common routing mistake;
- schedule automation and read meters without breaking realtime constraints;
- add a strip to a running mixer, and settle its smoothers before an offline render so the first block opens at the configured values;
- decide when to move from the guide to the field-by-field Mixing Scene JSON reference.
Pick the right entry point
Choose by whether the app renders once, owns a block-processing loop, or needs synchronized playback and export.
| Your situation | Use | Why |
|---|---|---|
| You have a handful of stereo stems and want one rendered file | mixStereo(...) | One call, plain arrays, nothing to clean up |
| You are building a browser mixer with playback and export | SonareEngine | Mirrors controls to realtime playback and offline rendering |
| You need sends, buses, inserts, automation, meters, or a saved project | Mixer.fromSceneJson(...) | Persistent, serializable mixer state |
You are inside a browser AudioWorklet or audio callback | WASM Mixer.createRealtimeBuffer() | Reuses buffers, never allocates per block |
If you know you want a scene but not yet what should be in it, the optional Mixing Assistant measures a set of tracks and proposes one — trims, faders, pans, inserts, and sends, each with a written reason. It only suggests: loading the result into a mixer remains your own explicit step.
One engine, every runtime
The same mixer engine is exposed through WASM/JS, Node native, Python, the C ABI, and the CLI. Names follow each language's convention (mixStereo ↔ mix_stereo; fromSceneJson ↔ from_scene_json), while the routing graph, scene JSON, and DSP are identical. The CLIs are the exception, and they differ from each other: the Python CLI's mix renders a scene in one shot (one --input WAV per strip), while the native CLI's mix-strip is a single-file, single-strip processor with no --scene. A persistent multi-strip Mixer you drive block by block is a library feature (WASM/JS, Node, Python, the C ABI, and C++); neither CLI has one. See Binding Parity for the per-runtime table.
The channel strip, signal by signal
A channel strip is one track lane. Understanding the order in which a strip processes audio is the key to predicting what every control does — for example, why a post-fader send follows the fader but a pre-fader send does not.
libsonare processes each block of a strip in this exact order:
Reading the chain top to bottom:
- Input trim — a clean gain stage before anything else, used to set a sensible working level (see gain staging). This is not the fader; trim is for "get the level right going in", the fader is for "balance against other tracks".
- Polarity invert — flips the sign of the left and/or right channel. Used to fix a track recorded out of phase with another.
- Channel delay — a per-strip sample delay. It delays only this strip; it is excluded from the engine's delay compensation.
- EQ — a built-in parametric EQ, set through the scene's
eqfield. It always runs pre-fader, so the fader rides the EQ'd signal. (The C++ChannelStripcan move it post-fader withset_eq_position; scene JSON and the bindings do not expose that.) - Pre-fader inserts — your named processors (compressor, de-esser, …) running before the fader.
- Pre-fader tap — the point a pre-fader send and the pre-fader meter read from. Because it is before the fader, moving the fader does not change a pre-fader send level.
- Fader (+ VCA offset) — the main level control. A VCA group (one fader that trims several strips at once) adds its offset here, so a single group fader can ride many strips without re-routing their audio.
- Pan — places the signal in the stereo field using the strip's pan mode and pan law.
- Stereo width — narrows or widens the side signal (see mono compatibility).
- Post-fader inserts — processors that should react to the post-fader level.
- Post-fader tap + goniometer — passes signal to post-fader sends, the output meter, and the goniometer history buffer.
Pre-fader vs post-fader is not cosmetic
A post-fader send follows the fader: pull the fader down and the reverb sent from that track follows it down — the reverb stays proportional to the dry signal. A pre-fader send is independent of the fader, which is what you want for a headphone/cue mix or a fully wet effect return. Choosing the wrong one is the most common routing mistake.
How this maps to the code
The order above is exactly ChannelStrip::process_segment:
input_trim -> polarity -> alignment_delay -> eq(pre) -> pre-inserts
-> [pre tap] -> fader(+VCA) -> panner -> width
-> post-inserts -> [goniometer + post tap]The pre/post taps are pre-allocated scratch buffers, so a send never allocates on the audio thread. Each scheduled automation event (fader, pan, width, send, insert) is applied at its sample position inside this same per-segment loop, which is what makes automation sample-accurate.
One-shot mixing: mixStereo
For "I have a few stems, give me one stereo file", mixStereo is the whole story. It takes per-track left/right channels plus parallel arrays of settings and returns the rendered master plus per-track meters.
import { init, mixStereo } from '@libraz/libsonare';
await init();
const mix = mixStereo([vocalL, musicL], [vocalR, musicR], sampleRate, {
inputTrimDb: [3, 0], // nudge the quiet vocal up before the fader
faderDb: [-3, -12], // balance: vocal forward, music back
pan: [0, -0.2], // music slightly left
width: [1, 0.9], // narrow the music a touch for mono safety
// panMode / muted are optional and also accept per-track arrays
});
// mix.left, mix.right -> Float32Array master
// mix.meters[i] -> per-track MixMeterSnapshot (see "Metering")import libsonare as sonare
mix = sonare.mix_stereo(
[(vocal_l, vocal_r), (music_l, music_r)],
sample_rate=48000,
input_trim_db=[3, 0], # nudge the quiet vocal up before the fader
fader_db=[-3, -12], # balance: vocal forward, music back
pan=[0, -0.2], # music slightly left
width=[1, 0.9], # narrow the music a touch for mono safety
)
# mix.left, mix.right -> master samples
# mix.meters[i] -> per-track MixMeterSnapshot (see "Metering")# The native CLI's mix-strip processes ONE file through ONE channel strip.
# Combining several stems with per-track settings in one call is binding-only
# (mixStereo above); the Python CLI's scene-based `mix` is the other CLI route.
sonare-cli mix-strip vocal.wav \
--input-trim-db 3 \
--fader-db -3 \
--pan 0.2 \
--pan-mode stereopan \
--width 1.1 \
-o out.wavScalar or per-track
In JavaScript, mixStereo options accept either a single value (applied to all tracks) or an array (one entry per track). faderDb: -3 lowers everything by 3 dB; faderDb: [-3, -12] sets each track independently.
In Python, mix_stereo(...) expects per-strip sequences for fader_db, pan, width, muted, and input_trim_db; pan_mode may be one value for all strips or a per-strip sequence.
Use mixStereo for offline utilities, batch scripts, and the simplest path to a rendered file. When you outgrow it — the moment you need a reverb send, a subgroup, automation, or a saved project — move to the scene-based Mixer.
Scene-based mixing: Mixer
A scene is a plain-JSON description of a whole mixer: its strips, their inserts and sends, the buses, VCA groups, and the connections between them. Mixer.fromSceneJson(...) compiles that description into a routing graph you can feed block by block.
import { init, Mixer, mixingScenePresetJson, mixingScenePresetNames } from '@libraz/libsonare';
await init();
mixingScenePresetNames(); // ['vocalReverbSend', 'drumBusSubgroup', 'commentaryDucking']
const sceneJson = mixingScenePresetJson('vocalReverbSend'); // an editable starting point
const mixer = Mixer.fromSceneJson(sceneJson, sampleRate, /* blockSize */ 512);
try {
// Feed one block of per-strip stereo audio; get the routed stereo master back.
// Strip 1 (vocal-verb-return) is fed by the vocal-verb aux bus, so its own input stays silent.
const silence = new Float32Array(512);
const out = mixer.processStereo(
[vocalBlockL, silence],
[vocalBlockR, silence],
);
const vocalMeter = mixer.stripMeter(0, 'postFader'); // see "Metering"
} finally {
mixer.delete(); // the WASM handle is NOT garbage-collected — always release it
}import numpy as np
import libsonare as sonare
sonare.mixing_scene_preset_names() # ['vocalReverbSend', 'drumBusSubgroup', 'commentaryDucking']
scene_json = sonare.mixing_scene_preset_json("vocalReverbSend")
with sonare.Mixer.from_scene_json(scene_json, sample_rate=48000, block_size=512) as mixer:
# Strip 1 (vocal-verb-return) is fed by the vocal-verb aux bus; its own input stays silent.
silence = np.zeros(512, dtype=np.float32)
block = mixer.process_stereo(
[vocal_block_l, silence],
[vocal_block_r, silence],
)
vocal_meter = mixer.strip_meter(0, tap="postFader") # see "Metering"# The Python CLI's `mix` renders a scene one-shot: it loads the scene, feeds one
# --input WAV per strip, and writes the master. A persistent, block-by-block
# Mixer you can re-parameterize between blocks is binding-only (WASM/JS, Node,
# Python). The native CLI has no --scene at all; its `mix-strip` is one strip.
sonare mixing-presets # list the built-in scene preset names
sonare mixing-preset --preset vocalReverbSend # print one scene's JSON to stdout
# An --input names its strip (ID=WAV, default: the file's base name); a strip no input names is silent.
sonare mix --scene scene.json --input vocal=vocal.wav -o master.wavAlways release the mixer
Mixer, like every embind object, holds a WASM heap handle that JavaScript's garbage collector cannot reclaim. Call mixer.delete() (or its alias destroy() — both bindings accept either name) in a finally block. Leaking handles will slowly exhaust WASM memory in long sessions.
The full scene schema, every field, and annotated preset JSON live in Mixing Scene JSON. The three built-in presets (vocalReverbSend, drumBusSubgroup, commentaryDucking) are not opaque — load one, edit it, and re-serialize it with toSceneJson() (or Python to_scene_json()) to learn the format by example.
Check sceneWarnings() after loading a scene
Loading a scene reports every key the reader did not consume (a misspelled field such as strips[0].faderDB, or an insert param no processor reads) as a non-fatal warning, readable with mixer.sceneWarnings() (Python mixer.scene_warnings()). The scene still loads; those keys just take no effect. Reading the warnings right after fromSceneJson(...) is the cheapest way to catch a knob that silently "does nothing". masteringInsertParamNames(name) lists the keys an insert accepts.
Buses, roles, and the routing graph
A bus is a shared destination. Strips connect to buses, buses connect to other buses, and one bus is the master.
Each strip's own path runs through its pre-fader inserts, the fader, and its post-fader inserts before it reaches a bus; the two tap dots on every lane are where a pre-fader or post-fader send copies the signal off toward an aux bus, and each bus runs its own insert chain once on the summed signal. Each bus carries a role:
| Role | Meaning |
|---|---|
master | The final stereo output. Every signal eventually reaches it. |
aux | A parallel destination for sends, typically an effect return (reverb, delay). |
submix (any other string) | A group of strips processed together before the master (a "drum bus"). The string is a label; see below. |
master is the only role string the graph treats specially; aux is simply the default value a bus gets when you do not give it one. Any other role string (submix, subgroup, group, …) is treated as a generic non-master bus. A misspelled "mastre" therefore makes an ordinary bus, and the master becomes the bus with id master or an implicit one. The built-in drumBusSubgroup preset labels its drum bus subgroup, so a printed scene may show "role": "subgroup" rather than "submix".
A bus runs its insert chain once per block, on the sum of everything feeding it, so a compressor or saturator on a group bus acts on the group rather than on each source separately. The realtime engine keeps the same rule when clip audio and hosted instruments both feed a bus; see A bus insert chain sees the summed signal, once.
Connections form a graph. Mixer.fromSceneJson builds and compiles that graph while constructing the mixer, so the returned mixer is ready to process immediately. After construction, buses are added and removed with mixer.addBus(id, role?) (role defaults to 'aux') and mixer.removeBus(id), and mixer.busCount() reports the current count. mixer.setOutputBus(sourceId, busId) (Python set_output_bus; C sonare_mixer_set_output_bus) routes a strip or bus into a submix after construction, without rewriting scene JSON: it replaces the source's main connection, including a scene-authored strip-to-strip edge. It takes ids rather than strip indexes, and refuses an unknown id, a destination that is not a bus, the master as source, a self-route, or an edit that would close a cycle through connections or sends with a coded InvalidParameter error naming the ids, leaving the topology unchanged. A submix added with addBus reaches the master only once it is routed there too. A topology change marks the graph dirty, and it is recompiled lazily on the next processStereo call — or eagerly when you call compile().
Call compile() after a topology change, before the next timing-critical block. Topology changes include:
- adding a strip
- adding or removing a bus
- adding or removing a send
- re-routing a strip or bus with
setOutputBus
Parameter changes, automation changes, and VCA group changes (adding, removing, or adjusting group gain) do not need a recompile — VCA groups only adjust live strip gain offsets and are not part of the routing graph.
Strip addressing differs by runtime:
| Runtime | How to address strips |
|---|---|
| WASM | Mixer control methods use numeric strip indexes. Use stripById(id) first when you have a scene id. |
| Node native / Python | Most control methods accept either a numeric index or a strip id string. |
Adding a strip: addStrip
Every binding builds a Mixer from a scene — there is no empty-mixer constructor — so mixer.addStrip(id, metering?) (Python mixer.add_strip(strip_id, ...)) is how a mixer grows after construction: a track that arrives mid-session, or a scene loaded as a template and filled in from code. The binding-level signature is on the JS API page.
You supply the id, which must be unused (a duplicate throws). Nothing is returned: the strip belongs to the mixer and is addressed afterwards by index — it is appended, so it is stripCount() - 1 — or through stripById(id). Everything else starts at the strip defaults: 0 dB trim and fader, center pan, width 1, no inserts, no sends, and its output routed straight to master. To feed a submix bus instead, route the strip after adding it with mixer.setOutputBus('room-mic', 'drum-bus') (Python set_output_bus) and call compile(). A VCA group whose member list already names the id picks the strip up as soon as it exists.
The one decision that can only be made here is metering, because a strip's meter buffers are sized when it is built. The default keeps both meters in full — LUFS plus true peak at 4x, about 1.4 MB per strip at 48 kHz. { lufs: false } shrinks each meter to about 83 KB, and { enabled: false } drops both (about 145 KB) for a strip whose snapshots you never read. The object is the scene's strips[].metering under the same names; a truePeakOversample outside 0–16 throws. The meter resolves the request to 2x for 2, 8x for 8–16, and 4x for anything else, so 0 gives the library default of 4x.
mixer.addStrip('room-mic', { lufs: false });
const roomMic = mixer.stripCount() - 1; // appended; stripById('room-mic') resolves the same index
mixer.setFaderDb(roomMic, -8);
mixer.setPan(roomMic, 0.4, 'stereoPan');
mixer.compile(); // a new strip is a topology change
mixer.settle(roomMic); // see the next sectionprocessStereo takes one stereo pair per strip in strip order, so the new strip is one more input on the end — strips after the last pair you pass receive silence, and passing more pairs than stripCount() is rejected with an argument error naming both counts (RangeError in WASM and Node, SonareValueError in Python).
Settle before an offline render: settle
Input trim, the fader (with its VCA offset), pan, and width are smoothed: each glides toward a new value with a 5 ms time constant (about 63% of the way after 5 ms, settled after roughly 25 ms), so a live fader move never clicks. Those smoothers are seeded when the strip is built — at 0 dB, centre, and width 1 — and the values from the scene are applied afterwards. So even a mixer fresh from fromSceneJson opens its first block at the defaults and slides to the scene's values: a strip whose fader reads −12 dB starts at 0 dB and fades down, a hard-left strip starts in the center and moves out, across the first 20 ms or so of output.
mixer.settle(strip) snaps those four stages to the values already set, so the next block opens exactly there. It clears nothing else — queued automation, meters, insert state, and the scene JSON are untouched, since the smoothers only move to values the scene already records.
Call it once per strip, after the last trim/fader/pan/width change and before the first processStereo of any render whose opening you keep — an offline bounce, a test fixture, a file rendered in one pass. A later change re-arms the glide from the current value, so settle again before the next render. In a live session leave it out: the glide is the click-free fader.
for (let i = 0; i < mixer.stripCount(); i++) mixer.settle(i); // WASM addresses strips by index
const out = mixer.processStereo(lefts, rights); // the first sample is at the configured valuesNode and Python settle also accept the strip id. The engine's project bounce settles every strip for you; a Mixer you drive block by block does not.
Removing sends and buses
Sends on a strip are addressed by index in add order. Removing one with removeSend(strip, sendIndex) shifts every later send down by one, so any send index you cached above the removed slot is now off by one — re-read indices after a removal (and recompile or process before reading routed results so the graph rebuilds).
Removing a bus also cleans up the routing that pointed at it: any send whose destination was that bus is dropped, because its target no longer exists. Audit a strip's sends after removing a bus they fed.
Buses that go nowhere
An explicit submix or aux bus with no path to the master is not flagged. The graph compiles and runs, but that bus contributes nothing to the output — usually a missing connection rather than an intentional dead end. sceneWarnings() reports unread keys, not unreachable buses, so check yourself that every bus you added is actually wired through to master.
VCA groups
A VCA group is a single fader that trims the level of several strips without re-routing their audio. VCA stands for voltage-controlled amplifier — historically a control voltage trimmed several console channels at once; here it is the same idea in software, one fader offsetting a group without re-routing audio.
For example, pull the "drums" VCA down 2 dB and the kick, snare, and overheads all drop 2 dB while still flowing to their own buses.
The group's gainDb is summed into each member's fader stage (step 7 above) as a delta on top of whatever fader value the strip already has. Because only the difference is applied, a per-strip fader trim you set inside the group survives — the group fader rides the whole set without overwriting the individual balance.
Add a strip's group and group gain with addVcaGroup(id, gainDb, members), adjust its gain with setVcaGroupGainDb(...), replace its membership without rebuilding the graph with setVcaGroupMembers(id, members), and remove it with removeVcaGroup(id); that group definition (gain and membership) round-trips through scene JSON. A setVcaOffsetDb(...) move is a live per-strip offset you can adjust during a session; it is also persisted as the strip's vcaOffsetDb and round-trips through scene JSON.
Solo and mute logic
setMuted(strip, true)silences a strip.setSoloed(strip, true)implies-mutes every other strip — except those you mark solo-safe withsetSoloSafe(...). Mark effect-return strips solo-safe so soloing a vocal still lets you hear its reverb return.
Solo, mute, and solo-safe take effect on the next block without a graph recompile.
A muted or solo-silenced strip is silent in full: its pre-fader sends go quiet with it, the gate reaches exactly zero gain when closed and exactly unity when open, and a solo-safe strip keeps sounding while another strip is soloed.
They are not instantaneous jumps, though. A lane's mute/solo gate is a smoothed gain with a 10 ms time constant, so a mute closes and opens without a click; for comparison, the pan smoother runs at 5 ms, because a pan move needs to track the gesture more tightly than a gate does. Both are fixed engine behavior — not the separate parameter-smoothing control that governs how bound insert parameters ramp.
Pan modes and pan laws
Pan mode is how a position maps to left/right; pan law is how loud the center is relative to the sides.
balance— for already-stereo material; turns one side down rather than moving the image.stereoPan— a true pan: it first folds the strip to mono,(L + R) / 2, then places that mono signal across the field. Anything that differs between the channels is lost, anti-phase content cancels, and a part that sat only in the left channel is halved (−6 dB) by the fold before the pan law applies. Use it for mono or near-mono sources, andbalanceordualPanfor real stereo material.dualPan— independent left and right positions (set withsetDualPan(...)), e.g. collapse a wide stereo track inward.
A strip can also delay its own signal by a whole number of samples with setChannelDelaySamples(...) — a per-strip alignment nudge for time-aligning a close mic against a room mic, or compensating a parallel path, without touching the pan.
The persistent Mixer also drives the other in-strip stages live, each a parameter change that takes effect on the next block with no graph recompile:
setInputTrimDb(strip, db)— the Input trim stage from the signal flow (the same gain as theinputTrimDbmixStereooption); distinct from the fader.setWidth(strip, width)— the Stereo width stage (widthmixStereooption):0= mono,1= original,>1= widened. Width runs after pan, on the mid/side form of the panned signal, so on a panned mono source it moves the position too: a hard-left source at width0ends up centered at −6 dB, and at width2the right channel gets an anti-phase copy at half level. Leave width at1on mono sources and place them with pan alone.setPolarityInvert(strip, invertLeft, invertRight)— the Polarity invert stage; flip the sign of the strip's left and/or right channel.
Pan law options are const3dB, const4.5dB, const6dB, and linear0dB in the JavaScript APIs. Python accepts the same values as enums/ints, or normalized strings such as const-3db and linear-0db.
Most laws dip the center by a fixed amount, so a sound panned to the center is not louder than one panned hard left or right:
| Law | Center dip | Use for |
|---|---|---|
const3dB | −3 dB | most material (default) — constant power |
const4.5dB | −4.5 dB | a compromise often used on consoles |
const6dB | −6 dB | constant amplitude — keeps the mono sum steady |
linear0dB | 0 dB | no center compensation at all |
const3dB is the constant-power law (cos/sin, 0.707 per channel at center): total energy stays put as you pan, which is what matters over speakers, while the coherent mono sum rises 3 dB toward the center. const6dB is the constant-amplitude law (0.5 per channel at center): the two channels add up to the same 1.0 as a hard pan, so the mono sum stays put and the power falls 3 dB instead. const4.5dB is the geometric mean of the two. linear0dB compensates nothing — both channels sit at unity in the center — so a centered source is 3 dB hotter in power and 6 dB hotter in the mono sum than a hard-panned one; pick it only when something downstream already applies its own pan compensation.
The center dip only reaches the output in stereoPan mode
Those figures are the raw pan-law gains. In balance — the default mode — the panner divides both gains by the louder (near) one, so the near channel is always unity and only the away channel is pulled down. The center stays at 0 dB whichever law you choose; the law only sets the near/away ratio. Switch the strip to stereoPan if you want the center dip itself. A strip whose sourceLayout is mono is a third case: in balance it is unity at center under every law, and in stereoPan each output channel gets the law's center gain (0.707 / 0.595 / 0.5 / 1.0 for the four laws, as re-measured on a settled strip).
Surround and multichannel
For buses wider than stereo, a strip carries a SurroundPan position — set with setSurroundPan(strip, { azimuth, divergence, lfe }) (Python set_surround_pan(strip, azimuth=..., divergence=..., lfe=...)). azimuth (−180…180°, 0 = front-centre), divergence (0 = point source, 1 = spread across the front), and lfe (0…1 send into the LFE — low-frequency effects — plane) are active; elevation and distance are reserved. The position is stored on the scene and round-trips through JSON, but the standalone Mixer (processStereo) still renders stereo only. The same surround panner runs in the realtime engine's surround group buses and in a project bounce at a matching output width, so set the position here and render the surround mix through either of those instead.
Automation
Every time-varying control is scheduled at an absolute sample position measured from the first processStereo call. Recompiling does not reset that clock, and queued automation survives the rebuild. Available lanes:
mixer.scheduleFaderAutomation(stripIndex, sampleRate * 8, -6, 's-curve'); // ride the vocal down at 8 s
mixer.schedulePanAutomation(stripIndex, sampleRate * 12, 0.3, 'linear');
mixer.scheduleWidthAutomation(stripIndex, sampleRate * 12, 1.2, 'linear');
mixer.scheduleSendAutomation(stripIndex, sendIndex, sampleRate * 16, -12, 'hold');
mixer.scheduleInsertAutomation(stripIndex, insertIndex, paramId, sampleRate * 4, value, 'exponential');The interpolation curve shapes the move between events:
| Curve | Shape | Use it for |
|---|---|---|
linear | straight ramp | general level/pan moves; on a dB fader lane this is already an even-sounding fade |
exponential | interpolates the value's magnitude on a log scale | frequency sweeps and linear-gain parameters |
s-curve | ease in and out | smooth, click-free transitions |
hold | jump, no ramp | stepped/instant changes |
exponential on a dB lane
Fader and send lanes carry dB values, and a straight line in dB is already an exponential change in amplitude. exponential bends the dB values a second time, and its shape depends on the direction: from −1 dB to −60 dB it reads −2.8, −7.9 and −22 dB at the quarter points (slow, then fast), while −60 to −1 dB reads −21, −7.6 and −2.7 dB (fast, then slow). A segment that starts or ends at exactly 0 dB, or crosses it, is interpolated linearly. Use linear or s-curve for fader fades.
Insert parameter automation
scheduleInsertAutomation addresses inserts by their index in the combined sequence [pre-inserts…, post-inserts…], and the paramId is processor-specific. Automating a compressor threshold to duck under a voice is a classic use — though for sidechain ducking the commentaryDucking preset wires it for you.
Metering
Every strip (and the master) exposes a rich MixMeterSnapshot. Read it post-render from mixStereo's meters[], or live from mixer.meterTap(strip, 'preFader' | 'postFader'). stripMeter(...) is a convenience alias in WASM/Python and a post-fader convenience path in Node native. busMeter(busId) reads the post-insert meter for a compiled bus, including master.
| Field | Tells you |
|---|---|
peakDbL / peakDbR | Sample peak per channel, over the measured window (see below) |
truePeakDbL / truePeakDbR / maxTruePeakDb | Inter-sample true peak, estimated with 4x oversampling per ITU-R BS.1770-4 — close to what a DAC (digital-to-analog converter) reconstructs, though a real reconstruction filter can peak a little higher |
rmsDbL / rmsDbR | Average (RMS) level per channel, over the measured window |
momentaryLufs / shortTermLufs / integratedLufs | Loudness over 400 ms / 3 s / the whole signal (gated, per BS.1770) |
correlation | −1…+1 phase correlation over the measured window; near +1 is mono-safe, negative warns of cancellation |
monoCompatWidth / monoCompatPeak / monoCompatSideRms / likelyMonoCompatible | Mono-compatibility summary |
gainReductionDb | How hard dynamics on the strip are working |
seq | Monotonic snapshot counter for change detection |
The measured window, and why a UI adds its own ballistics
Peak, RMS, and correlation cover exactly the audio of one call. For mixStereo that is the whole render. For a Mixer or the realtime engine it is the last processed block, so a 128-frame block at 48 kHz is a 2.7 ms window, and the reading jumps from block to block. The LUFS fields are different: their 400 ms and 3 s windows slide across blocks, so they do not depend on the block size.
Hardware-style meters smooth their readings with fixed ballistics: a VU meter integrates over about 300 ms, a peak program meter (PPM) rises within milliseconds and falls back slowly, and a peak-hold marker keeps the highest value for a second or two. libsonare reports the raw per-block values and leaves the ballistics to the display. A UI typically keeps a falling peak (for example 20 dB per second) and holds the maximum for about 1.5 s.
What is a goniometer?
A goniometer (also called a vectorscope) is a dot-cloud display of the left vs. right signal. It is usually drawn rotated by 45°, with x = (L − R) / √2 and y = (L + R) / √2. On that display a near-vertical line means mono, a wide blob means a wide stereo spread, and a horizontal smear warns of phase problems that may cancel in mono. It is the picture version of the correlation meter. readGoniometerLatest (below) returns raw { left, right } pairs, so apply the rotation yourself; plotted unrotated, a mono signal is a 45° diagonal.
The true-peak reading runs one reconstruction-filter group delay behind the block, so it does not depend on the block size. After the last block of a signal, call mixer.flushMeters() (Python flush_meters()) to read that pending stretch against silence and fold it into the snapshots; a later processStereo starts a new signal. Offline engine renders and the one-shot mixStereo do this for you. A short burst at the very end of a block shows the effect: truePeakDbL reads higher after the flush while the sample peak does not move.
The goniometer is a separate, time-domain view: readGoniometerLatest(strip, maxPoints) returns the most recent left/right sample pairs (oldest → newest) for plotting a stereo vectorscope. The core read is allocation-free: it copies the points from the strip's pre-allocated ring into caller-owned storage, so UI polling does not add allocations to the audio thread. The WASM binding then allocates a temporary result and returns a plain JavaScript array of plain { left, right } point objects. The JS call therefore allocates at the binding boundary, but its result is cloneable plain data that is safe to pass to structuredClone or postMessage.
Realtime and the AudioWorklet bridge
The mixer core is built for predictable audio callbacks: denormal guards, lock-free parameter changes, pre-allocated state, and graph-level plugin-delay compensation.
Call mixer.latencySamples() to read the compiled graph's reported latency when aligning it with dry or externally processed material. If the topology is dirty, this call compiles the graph before returning the value.
In the WASM package, processStereo allocates a fresh result array on every call. That is fine offline but forbidden inside a realtime audio callback. For those tight loops, use one of these allocation-free paths instead:
processStereoInto(inL, inR, outL, outR)— writes into caller-owned arrays. It accepts fewer input channels than strips and leaves the remaining strips silent, asprocessStereodoes; more channels than strips is aRangeError.createRealtimeBuffer()— returns reusable WASM-heap input/output views; fill the inputs, callprocess(), readoutLeft/outRight, repeat. The views are owned by the mixer and become invalid afterdelete().
When the host stops sending strip inputs, lookahead, reverb, and delay processors still hold audio in flight. Call mixer.tailSamples() to read the maximum processor-tail length (samples) in the compiled graph, then call mixer.drainTailStereo(numSamples) to render a zero-input block that flushes that tail into the master. drainTailStereo renders one block — it does not loop — and numSamples is bounded by the block size the mixer was built with, so drain in block-sized chunks until you have pulled tailSamples() frames:
let remaining = mixer.tailSamples();
while (remaining > 0) {
const n = Math.min(remaining, blockSize);
const tail = mixer.drainTailStereo(n); // one zero-input block
exportBlock(tail.left, tail.right);
remaining -= n;
}What are denormal guards?
Denormals are extremely small floating-point numbers (close to zero) that many CPUs process far more slowly than ordinary values. In an audio callback this bites when a reverb or delay tail fades out: as the samples shrink toward zero they slip into the denormal range and processing time can spike, causing dropouts. Denormal guards flush these tiny values to zero so each block takes a predictable amount of time.
Topology changes are not realtime-safe
Adding buses or sends marks the graph dirty and the next processStereo will recompile (which may allocate). Do structural changes during setup or a non-critical block, call compile(), then enter your tight render loop. Fader/pan/send/insert moves, automation, and VCA group changes are fine inside the loop.
Latency and plugin-delay compensation (PDC)
Lookahead processors, such as a limiter on a drum bus, add latency. If one path through the graph is delayed and another is not, the two arrive at the master misaligned.
Plugin-delay compensation (PDC) fixes that alignment problem:
- The engine measures each path's latency.
- It delays the shorter paths to match the longest path.
- The paths line up again at the master.
Per-strip channelDelaySamples is an intentional timing offset. It delays only that strip and does not increase reported latency or delay the other paths through PDC.
Recipes
Balance and bounce a few stems
The fastest possible mix. No buses, no automation — just trim, balance, and render.
const mix = mixStereo(lefts, rights, sampleRate, {
inputTrimDb: trims, // get each stem to a sane working level
faderDb: balances, // then balance them against each other
pan: positions,
});
exportWav(mix.left, mix.right, mix.sampleRate);Shared vocal reverb on an aux send
One reverb, fed by a post-fader send, returning on its own strip — the vocalReverbSend preset in miniature.
const mixer = Mixer.fromSceneJson(mixingScenePresetJson('vocalReverbSend'), sampleRate, 512);
// strip 0 = "vocal" (EQ + compressor inserts, post-fader send to the "vocal-verb" aux)
// strip 1 = "vocal-verb-return" (plate reverb insert, returns to master)
mixer.setSendDb(0, 0, -10); // more reverb
mixer.compile(); // only needed after topology editsBecause the send is post-fader, automating the vocal fader down also pulls its reverb down with it.
Drum subgroup with parallel compression
Route kick/snare/overheads into one submix bus, glue them with parallel compression and tape, and ride them all with a VCA — the drumBusSubgroup preset.
const mixer = Mixer.fromSceneJson(mixingScenePresetJson('drumBusSubgroup'), sampleRate, 512);
mixer.setVcaOffsetDb(/* a drum member strip */ 0, -1.5); // trim the whole kit livePodcast / commentary ducking
Duck a music bed under speech automatically using a sidechain. The commentaryDucking preset puts a dynamics.sidechainRouter on the bed keyed off the host strip.
const mixer = Mixer.fromSceneJson(mixingScenePresetJson('commentaryDucking'), sampleRate, 512);
// host + guest are VCA-grouped as "voices"; the music-bed strip ducks whenever the host speaksRelated
- Build a Browser Mixer — a working demo, implementation map, and playback/export boundaries
- Mixing Basics — the vocabulary, for newcomers
- Mixing Scene JSON — the full scene schema and annotated presets
- Mixing Assistant — measures a set of tracks and suggests a scene, with reasons
- Mastering Processors — the processors you load as strip/bus inserts
- Binding Parity — per-runtime API differences
- Mono Compatibility · Gain Staging · True Peak