Skip to content

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

StageRole
AnalysisTells you what a track is
EditingFixes timing and pitch of one track
MixingBalances several tracks into a stereo bus
MasteringPolishes 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:

InsertSend
Signal pathIn series — the whole signal passes throughIn parallel — a copy is routed to a bus
Typical useCompressor, EQ, de-esser on the track itselfShared reverb/delay several tracks feed
Wet/dryThe processor's own mix controls itDry stays on the strip; only the send copy is processed
Where it sitsIn a pre-fader or a post-fader slotTapped 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):

dBLinear gainWhat it means
0 dB1.0Unchanged
−3 dB0.707Half the power
−6 dB0.5Half the amplitude
−12 dB0.25A quarter of the amplitude
−∞ dB0Silence

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 mixStereo for one-shot stem rendering or Mixer.fromSceneJson for 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 situationUseWhy
You have a handful of stereo stems and want one rendered filemixStereo(...)One call, plain arrays, nothing to clean up
You are building a browser mixer with playback and exportSonareEngineMirrors controls to realtime playback and offline rendering
You need sends, buses, inserts, automation, meters, or a saved projectMixer.fromSceneJson(...)Persistent, serializable mixer state
You are inside a browser AudioWorklet or audio callbackWASM 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.

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

libsonare processes each block of a strip in this exact order:

Channel strip signal flow
Pre-fader sendStrip inputInput trimPolarity invertChannel delayEQ (pre)Pre insertsPre-fader tapFader + VCAPanStereo widthPost insertsPost-fader tapTo bus / masterPost send + meter
Signal order: trim -> polarity -> delay -> EQ (pre) -> pre-fader inserts -> fader -> pan -> width -> post-fader inserts -> bus. The two taps branch a copy to sends/meters without altering the main chain.

Reading the chain top to bottom:

  1. 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".
  2. Polarity invert — flips the sign of the left and/or right channel. Used to fix a track recorded out of phase with another.
  3. Channel delay — a per-strip sample delay. It delays only this strip; it is excluded from the engine's delay compensation.
  4. EQ — a built-in parametric EQ, set through the scene's eq field. It always runs pre-fader, so the fader rides the EQ'd signal. (The C++ ChannelStrip can move it post-fader with set_eq_position; scene JSON and the bindings do not expose that.)
  5. Pre-fader inserts — your named processors (compressor, de-esser, …) running before the fader.
  6. 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.
  7. 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.
  8. Pan — places the signal in the stereo field using the strip's pan mode and pan law.
  9. Stereo width — narrows or widens the side signal (see mono compatibility).
  10. Post-fader inserts — processors that should react to the post-fader level.
  11. 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.

MIXER · SENDSIDLE
Pre-fader vs post-fader sends — drag the fader

One channel feeds two sends and the main output. Pull the channel fader down: the POST-fader send and the MAIN output follow it, because they are tapped after the fader; the PRE-fader send is tapped before it and stays put. Press play to hear it — the dry tone (post-fader) fades out while the pre-fader send (a stand-in for a reverb/aux return) keeps sounding even with the fader all the way down. That is why a vocal reverb fed pre-fader does not disappear when you ride the vocal down — the classic routing surprise.

Channel fader
0 dB
How this maps to the code

The order above is exactly ChannelStrip::process_segment:

text
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.

typescript
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")
python
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")
bash
# 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.wav

Scalar 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.

typescript
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
}
python
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"
bash
# 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.wav

Always 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.

Strips, buses, sends, and the master
pre insertsfaderpost insertspre insertsfaderpost insertspre insertsfaderpost insertsinsertsinsertsinsertsstrip 1strip 2strip 3submix busaux busmasterpre-fader sendpost-fader sendoutputPre-fader sends ignore the fader; post-fader sends follow it. The strip’s own path continues either way.

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:

RoleMeaning
masterThe final stereo output. Every signal eventually reaches it.
auxA 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:

RuntimeHow to address strips
WASMMixer control methods use numeric strip indexes. Use stripById(id) first when you have a scene id.
Node native / PythonMost 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.

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

processStereo 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.

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

Node 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 with setSoloSafe(...). 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, and balance or dualPan for real stereo material.
  • dualPan — independent left and right positions (set with setDualPan(...)), 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 the inputTrimDb mixStereo option); distinct from the fader.
  • setWidth(strip, width) — the Stereo width stage (width mixStereo option): 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 width 0 ends up centered at −6 dB, and at width 2 the right channel gets an anti-phase copy at half level. Leave width at 1 on 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:

LawCenter dipUse for
const3dB−3 dBmost material (default) — constant power
const4.5dB−4.5 dBa compromise often used on consoles
const6dB−6 dBconstant amplitude — keeps the mono sum steady
linear0dB0 dBno 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).

MIXER · PAN LAWIDLE
Pan laws: what happens to level as a source moves

A mono source panned across one mixer strip. Top: the left and right gains the strip applies. Bottom: total power and the mono sum (L + R), in dB against a hard pan, with the other laws ghosted. In stereoPan, const3dB keeps the power flat and the mono sum rises 3 dB at the center; const6dB keeps the mono sum flat and the power dips 3 dB. Switch to balance and the center is unity on both channels under every law. Press play to sweep noise from left to right.

Pan law
Pan mode

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:

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

CurveShapeUse it for
linearstraight rampgeneral level/pan moves; on a dB fader lane this is already an even-sounding fade
exponentialinterpolates the value's magnitude on a log scalefrequency sweeps and linear-gain parameters
s-curveease in and outsmooth, click-free transitions
holdjump, no rampstepped/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.

FieldTells you
peakDbL / peakDbRSample peak per channel, over the measured window (see below)
truePeakDbL / truePeakDbR / maxTruePeakDbInter-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 / rmsDbRAverage (RMS) level per channel, over the measured window
momentaryLufs / shortTermLufs / integratedLufsLoudness 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 / likelyMonoCompatibleMono-compatibility summary
gainReductionDbHow hard dynamics on the strip are working
seqMonotonic 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.

METERS · LOUDNESSIDLE
Loudness metering — LUFS, true-peak, and range

The bar tracks momentary loudness as the clip plays; the panel is the loudness over time. Integrated LUFS is the single overall number, true-peak estimates peaks between samples, and LRA captures how much the loudness moves. Switch the window to compare the fast momentary meter with the smoother short-term one — each reports only once its window has filled, so the short-term contour starts three seconds into the clip.

Window

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.

LOUDNESS · TRUE PEAKIDLE
Inter-sample peaks — why a master clips on playback

The dots are samples of a sine wave; the curve shows its analytic waveform. A crest can fall between samples, above every stored value. The gap depends on frequency and phase, so it does not increase smoothly toward Nyquist. Compare the analytic peak with the oversampled meter reading. Raising the sample peak toward 0 dBFS can leave an inter-sample over that clips a converter or processor without sufficient headroom.

Sample peak
-0.3 dBFS
Frequency (×Nyquist)
0.4

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, as processStereo does; more channels than strips is a RangeError.
  • createRealtimeBuffer() — returns reusable WASM-heap input/output views; fill the inputs, call process(), read outLeft/outRight, repeat. The views are owned by the mixer and become invalid after delete().

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:

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

  1. The engine measures each path's latency.
  2. It delays the shorter paths to match the longest path.
  3. 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.

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

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

Because 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.

DYNAMICS · COMPRESSORIDLE
Parallel compression — squash a copy, keep the punch

Blend the dry signal with a compressed copy. The dry path preserves more of the original transients than fully wet compression. This example applies no makeup gain, so blending does not boost quiet passages and output level changes with the mix. Compare the transfer curve and gain envelope, then audition the result.

Threshold
-28 dB
Ratio
8 :1
Blend
50 %
typescript
const mixer = Mixer.fromSceneJson(mixingScenePresetJson('drumBusSubgroup'), sampleRate, 512);
mixer.setVcaOffsetDb(/* a drum member strip */ 0, -1.5);  // trim the whole kit live
Podcast / 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.

typescript
const mixer = Mixer.fromSceneJson(mixingScenePresetJson('commentaryDucking'), sampleRate, 512);
// host + guest are VCA-grouped as "voices"; the music-bed strip ducks whenever the host speaks