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.
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.
Where mixing sits in the pipeline
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 that 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.
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;
- decide when to move from the guide to the field-by-field Mixing Scene JSON reference.
Pick the right entry point
The engine is deliberately split into two levels. Start at the top row and only move down when you actually need the extra control.
| 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 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 |
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 available only through WASM/JS, Node, and Python. 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 both time-aligns a track and contributes to the engine's delay compensation.
- EQ — a built-in parametric EQ. It sits pre-fader by default (so the fader rides the EQ'd signal), but can be moved post-fader.
- 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.
- Post-fader inserts (and EQ, if you moved it post-fader) — processors that should react to the post-fader level.
- Stereo width — narrows or widens the side signal (see mono compatibility).
- 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 → eq(post) → post-inserts → width → [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.
const out = mixer.processStereo(
[vocalBlockL, returnBlockL],
[vocalBlockR, returnBlockR],
);
const vocalMeter = mixer.stripMeter(0, 'postFader'); // see "Metering"
} finally {
mixer.delete(); // the WASM handle is NOT garbage-collected — always release it
}import libsonare as sonare
sonare.mixing_scene_preset_names() # ['vocalReverbSend', 'drumBusSubgroup', 'commentaryDucking']
scene_json = sonare.mixing_scene_preset_json("vocalReverbSend")
mixer = sonare.Mixer.from_scene_json(scene_json, sample_rate=48000, block_size=512)
block = mixer.process_stereo(
[vocal_block_l, return_block_l],
[vocal_block_r, return_block_r],
)
vocal_meter = mixer.strip_meter(0, tap="postFader") # see "Metering"
mixer.close() # release the native handle# 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
sonare mix --scene scene.json --input vocal.wav --input reverb-return.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 audits every insert's params: keys that no processor reads (usually typos, or keys meant for a different processor) are collected as non-fatal warnings, 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.
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 taps | After the relevant fader stage | 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.
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 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 | A group of strips processed together before the master (a "drum bus"). |
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. The built-in drumBusSubgroup preset labels its drum bus subgroup, so a printed scene may show "role": "subgroup" rather than "submix".
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. 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 or removing a bus
- adding or removing a send
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. |
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() only reports ignored insert params, 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.
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 that moves a mono-ish source across the field.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.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 single-channel (mono) strip is a third case: it receives the law's combined energy sqrt(l² + r²) as one gain, which is unity at center under the default const3dB.
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=...)). Phase 1 honors 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); elevation and distance are reserved. The position is stored on the scene and round-trips through JSON, but the offline Mixer still renders stereo — the surround panner that consumes these values runs in the realtime engine's surround group buses, so set the position here and render the surround mix through the engine.
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 |
exponential | fast then slow | natural-sounding fades |
s-curve | ease in and out | smooth, click-free transitions |
hold | jump, no ramp | stepped/instant changes |
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 |
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 | Short-term average level |
momentaryLufs / shortTermLufs / integratedLufs | Loudness over 400 ms / 3 s / the whole signal |
correlation | −1…+1 phase correlation; 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 |
What is a goniometer?
A goniometer (also called a vectorscope) is a dot-cloud display of the left vs. right signal. It shows the stereo image at a glance: 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.
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 latest-read path is allocation-free, so UI polling does not add audio-thread pressure.
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.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 feeds into the same calculation. Recompile after changing it so PDC runs again.
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
- Mixing Basics — the vocabulary, for newcomers
- Mixing Scene JSON — the full scene schema and annotated presets
- Mastering Processors — the processors you load as strip/bus inserts
- Binding Parity — per-runtime API differences
- Mono Compatibility · Gain Staging · True Peak