Skip to content

Mixing Scene JSON ​

A scene is the pure-data description of a whole mixer.

It is the format Mixer.fromSceneJson(...) reads and toSceneJson() writes. In Python, the names are from_scene_json(...) and to_scene_json().

The format is identical across WASM, Python, Node, the C ABI, and C++. Because it is plain JSON, you can store it with a project, diff it in git, hand-edit it, and reload it later. A JSON Schema for it, mixer-scene.schema.json, ships inside the npm and Python packages so a hand-written or generated scene can be checked before it ever reaches a mixer — see Validating a scene against the schema.

If you have not met strips, sends, and buses yet, read Mixing Basics and the Mixing Engine guide first. This page is the field-by-field reference.

The demo below shows the same routing ideas without JSON: lanes feed a small mixer, sends and levels change the output, and the meters respond immediately. Use it first if the scene fields feel abstract, then come back to the schema with the signal flow in mind.

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

What You Will Learn ​

By the end of this page you should be able to:

  • recognize the top-level scene shape and the role of strips, buses, VCA groups, and connections;
  • edit or generate a scene without confusing strip controls, inserts, sends, and routing edges;
  • understand which fields have defaults and which identifiers must match across the graph;
  • validate a scene against the shipped JSON Schema, and know which mistakes only the loader can catch;
  • use a built-in preset as the safest starting point for custom scene JSON.

Learn the format by example

The fastest way to understand the format is to print a built-in preset and read it: mixingScenePresetJson('vocalReverbSend'). Every field below appears in that output, so you can match each key to a real value. The annotated preset at the end of this page does exactly that.

Top-level shape ​

json
{
  "version": 1,
  "strips": [],
  "buses": [],
  "vcaGroups": [],
  "connections": []
}
FieldTypeMeaning
versionintegerFormat version. Currently must be 1; other values are rejected. The schema requires the key; the loader treats a missing version as 1.
stripsarrayThe track lanes (see Strip).
busesarrayShared destinations, including the master (see Bus).
vcaGroupsarrayLevel groups that trim several strips at once (see VCA group).
connectionsarrayThe routing graph edges (see Connection).

The loader accepts unknown keys, and reports them

The parser still loads a scene that carries fields it does not recognize, so a forward-compatible producer can add metadata without breaking older readers. A key that starts with $ or x- is reserved for that purpose and is never reported. Any other key the reader does not consume is a likely typo — processorName (wrong) vs processor (right), or faderDB for faderDb — and it takes no effect, so the field keeps its default.

Every such key is reported as a non-fatal warning with its path from the document root, e.g. unknown scene key 'strips[0].faderDB'. The scene still loads. Read the warnings right after loading instead of hunting for a knob that "does nothing":

  • Mixer.sceneWarnings() (Python scene_warnings()) after Mixer.fromSceneJson(...);
  • the string list that Project.setMixerSceneJson(...) returns (Python set_mixer_scene_json), empty when the scene is clean;
  • the unknown_scene_key diagnostic that Project.fromJsonWithDiagnostics(...) returns in its diagnostics text for the scene embedded in a project document, with a path such as scene.strips[2].faderDB (Project.fromJson loads the same document and drops the diagnostics). The rest of the project document is not checked for unknown keys.

Insert params keys are covered by the same list: a param key that no processor consumed is reported too. Enumerate the keys an insert actually reads with masteringInsertParamNames(name) (Python mastering_insert_param_names(name)).

The shipped schema catches the same mistakes before any load. Every object uses additionalProperties: false with patternProperties allowing $ and x- annotations, so misspellings such as faderDB fail validation while reserved metadata keys validate.

The live-engine setters are stricter than a whole-scene load. setTrackStripJson, setBusStripJson, setMasterStripJson (and the Node, WASM, and Python setters over them) and the strip EQ band setters (setTrackStripEqBand, setTrackStripEqBandJson, and the bus and master counterparts) refuse a key they do not consume with InvalidParameter, and the message names it: unknown strip key 'strips[0].faderDB', unknown band key 'gainDB'. The legacy snake_case spellings and the $ / x- keys are accepted.

Strip ​

Each strip object describes one channel lane. All numeric fields have sensible defaults, so a minimal strip is just { "id": "vocal" }.

FieldTypeDefaultMeaning
idstring— (required)Unique strip identifier used by connections, sends, and VCA members
inputTrimDbnumber0Gain before any processing (the first stage in the strip signal flow)
faderDbnumber0Main fader level
vcaOffsetDbnumber0Per-strip VCA trim summed into the fader stage (the live setVcaOffsetDb(...) value; separate from any VCA group gainDb, which is applied as a delta on top and is not stored in this field)
pannumber0Pan position, -1 (left) … +1 (right). The loader clamps to that range; the schema rejects values outside it
widthnumber1Stereo width / side multiplier (0 = mono, 1 = unchanged, 2 = doubled). Clamped to 0 … 2 on load; the schema rejects values outside it
mutedbooleanfalseSilences the strip
soloedbooleanfalseImplies-mutes other (non-solo-safe) strips
soloSafebooleanfalseNever implied-muted by another strip's solo
panModeinteger00 = balance, 1 = stereo pan, 2 = dual pan. Anything else is rejected at load
dualPanLeftnumber-1Left position in dual-pan mode (default is identity hard-left, preserving the stereo image); clamped to -1 … +1
dualPanRightnumber1Right position in dual-pan mode (default is identity hard-right); clamped to -1 … +1
sourceLayoutstring"stereo"Channel layout of the source feeding the strip: "mono", "stereo", "5.1", or "7.1". The writer omits it at the stereo default; any other string is rejected at load
surroundPanobjectidentitySurround-pan position for wider-than-stereo hosts. azimuth, divergence, and lfe affect the realtime engine's 5.1/7.1 group-bus panner; elevation and distance are reserved. Values are clamped on load and round-tripped through scene JSON; the writer omits the object at the centred default. The standalone offline Mixer remains stereo and therefore does not apply it
meteringobjectall onWhich meters the strip computes: enabled, lufs, truePeak (booleans, default true) and truePeakOversample (integer, default 4, must be 1 … 16; the meter resolves it to 2x for 2, 8x for 8–16, and 4x otherwise). The writer omits the object when every meter is at its default
polarityInvertLeftbooleanfalseInverts the left channel polarity
polarityInvertRightbooleanfalseInverts the right channel polarity
panLawinteger00 = const 3 dB, 1 = const 4.5 dB, 2 = const 6 dB, 3 = linear 0 dB. Anything else is rejected at load
channelDelaySamplesinteger0Per-strip delay, 0 … 192000; delays only this strip and is excluded from PDC. Out of range is rejected at load
eqobjectidentityThe strip's built-in equalizer, which runs pre-fader: enabled (default true) and bands, at most 24 EQ bands. Omitted by the writer while it is enabled and every band is at its default. Scene JSON is the only way to configure it from WASM, which has no EQ setters on Mixer
insertsarray[]In-series processors (see Insert)
sendsarray[]Parallel sends to buses (see Send)

Enums are integers in the file, strings in the API

The scene file stores panMode and panLaw as integers, but insert slot and send timing are stored as the short string tokens "pre" / "post". The JavaScript runtime methods accept friendly strings — setPanLaw(strip, 'const3dB'), addSend(..., 'postFader'). Python accepts the same send/tap names, but pan-law strings use normalized names such as 'const-3db', 'const-4.5db', 'const-6db', or 'linear-0db' (or the enum/int value). Both map to the same underlying value; the difference is just file format vs. ergonomic API.

Serializing a scene after runtime pan edits preserves the strip's current panMode. Use Mixer.toSceneJson() / Mixer.to_scene_json() instead of rebuilding the pan fields by hand.

Insert slot and send timing must be strings. A non-string value — e.g. a numeric "timing": 1 — is rejected at load time, with the reason send timing must be a string ("pre" or "post"). Always write "pre" or "post".

How a rejected scene reaches you

Everything that stops a scene from building (malformed JSON, an unsupported version, an out-of-range field, a non-string timing) is reported with the C ABI's own code and the specific reason in the message.

RuntimeWhat you catchCode
WASMSonareError, message failed to build mixer from scene JSON: <reason>InvalidFormat for a malformed document, a fractional integer, or an out-of-range panMode, panLaw, channelDelaySamples, or metering.truePeakOversample; InvalidParameter for an unsupported version, a non-string slot or timing, an unknown layout, or a graph the build refuses
Node nativeSonareError with the same message and codesSame
PythonSonareError with the same message and codesSame
C ABInullptr from sonare_mixer_from_scene_jsonsonare_last_error_code(); the reason is in sonare_last_error_message()

Branch on code; log the message to see which field was wrong. Validating a scene before you hand it to a mixer still gives the earliest, path-naming message.

Field terms: dual pan, polarity invert, pan law, PDC
  • Dual pan (panMode: 2): pans the left and right channels to independent positions instead of moving the whole signal together. Useful for narrowing or re-placing an already-stereo source.
  • Polarity invert: multiplies a channel by −1, flipping the waveform. Used to fix a track recorded out of phase with another; it changes phase relationships, not perceived loudness on its own.
  • Pan law: how much the center is attenuated relative to hard-left/right so loudness stays even as you pan. const 3 dB is constant power, const 6 dB is constant amplitude (it keeps the mono sum steady), const 4.5 dB sits between the two, and linear 0 dB applies no center compensation. The center dip reaches the output only in stereo-pan mode (panMode: 1); in the default balance mode a centered strip stays at 0 dB under every law. See Mixing Engine.
  • PDC (plugin-delay compensation): when one path is delayed by a lookahead processor, the engine delays the shorter paths to match so everything lines up at the master. channelDelaySamples is excluded from that calculation.

Insert ​

An insert is a named processor running in series inside the strip (or a bus).

FieldTypeMeaning
slot"pre" | "post"Runs before or after the fader; defaults to "pre" when omitted. Note the short tokens — not preFader/postFader.
processorstringRequired. Processor id, e.g. eq.parametric, dynamics.compressor, effects.reverb.plate. See Mastering Processors for solo processors and Effects Inserts for the creative-FX catalog. An id the registry does not know is refused when the mixer is built, not by the schema.
paramsobjectThe processor's parameters, keyed by parameter name, e.g. { "thresholdDb": -18, "ratio": 2.5 }. Values are numbers or booleans, with two processor-specific exceptions: a string for a named rig or an embedded impulse response, and a per-band array for the acoustic room morph.
sidechainKeystringOptional. Strip id whose signal feeds this insert's external sidechain (e.g. ducking). Omitted when empty.

Sidechain and ducking

Normally a processor reacts to the audio passing through it. A sidechain makes it react to a different track instead: sidechainKey names that other strip. The classic use is ducking: a compressor on the music that turns the music down whenever the named voice strip is loud, so speech stays clear over a music bed.

On the live realtime engine, sidechain keys are bound with setLaneSidechain, setBusSidechain, and setMasterSidechain, which refuse a binding they cannot honor with InvalidParameter. canSetLaneSidechain(trackId, insertIndex, sourceTrackId), canSetBusSidechain, and canSetMasterSidechain ask the same question without changing anything and answer { ok, reason }, so a UI can gray out an impossible choice before the user makes it. reason is null exactly when ok is true; otherwise it is the first check that failed:

reasonThe binding is refused because
invalidTargetthe track id is 0, or the bus is not configured
insertOutOfRangethe strip has no insert at that index
undeclaredSourcethe source track or bus is not declared
invalidSourceKindthe source kind is neither a track nor a bus
selfKeya strip would key itself
cyclethe key would close a loop through outputs, sends, or other keys
tableFullthe sidechain binding table is full
planRefusedno delay-compensation plan exists for the alignment

The calls and their setup are in Realtime Engine.

params is an object; the escaped-string form is legacy

toSceneJson() writes params as a nested object ("params": { "ratio": 2.5 }), and that is the only shape the schema admits. The loader still accepts the older form, an escaped JSON string ("params": "{\"ratio\":2.5}"), so a scene saved by an earlier release loads unchanged; a validator rejects it as is not object. When you write a scene by hand, use the object form. Anything that is neither an object nor a string is refused at load with insert params must be a JSON object (or the legacy JSON string).

Send ​

A send routes a copy of the strip's signal to a destination bus. The timing field chooses whether the copy is tapped before the fader ("pre") or after it ("post") — see the channel-strip signal flow.

FieldTypeMeaning
idstringSend identifier, unique within the strip
destinationBusIdstringRequired. The id of the target bus. An id that no bus declares creates an implicit aux bus that routes to the master, so a misspelled destination is not rejected: it makes sound, and it raises a scene warning naming the strip and the bus (send 'a' targets undeclared bus 'nope'; created as implicit aux). Declare the bus you mean
sendDbnumberSend level in dB, default 0
timing"pre" | "post"Tapped before or after the fader (again, short tokens); defaults to "post"

Bus ​

FieldTypeMeaning
idstringRequired. Bus identifier (one bus is conventionally "master")
rolestring"master", "aux" (the default), or a group bus such as "submix"
layoutstringChannel layout of the bus: "mono", "stereo" (default), "5.1", or "7.1". The master bus carries the project output layout. Omitted by the writer at the stereo default
inputTrimDbnumberGain at the bus input, default 0
widthnumberStereo width of the bus, default 1, clamped to 0 … 2 like a strip's. Refused when layout is wider than stereo, since a surround bed has no stereo image to narrow or widen
polarityInvertLeft / polarityInvertRightbooleanPer-channel polarity flip on the bus, default false
pan, panMode, dualPanLeft, dualPanRight, panLawas on a stripSame meaning and ranges as the strip fields. Anything other than the defaults is refused when layout is wider than stereo
eqobjectThe bus's built-in equalizer; the same shape as the strip's eq
insertsarrayProcessors on the bus itself (same Insert shape as a strip)

The writer omits layout, inputTrimDb, width, the pan fields, eq, and both polarity flags whenever they sit at their defaults, so a plain stereo bus serializes as just id, role, and inserts.

EQ bands ​

A strip's or bus's eq object is { "enabled": true, "bands": [...] }, with at most 24 bands. Each band has the same fields as the realtime engine's EqBand:

FieldMeaning
enabledDefaults to false: a band does nothing until it is enabled
typePeak (default), LowShelf, HighShelf, LowPass, HighPass, BandPass, Notch, TiltShelf, FlatTilt, or AllPass
frequencyHz, gainDb, qCenter or corner frequency (default 1000), gain (default 0), and resonance or slope
slopeDbOct, coeffMode, placement, phase, soloed, bypassedFilter slope, coefficient mode, stereo placement, phase mode, and per-band solo and bypass
dynamic, thresholdDb, ratio, rangeDb, attackMs, releaseMs, …The per-band dynamic-EQ fields; see eqBand in the schema for the full list

A band left out of the list stays at the identity, and the writer drops the whole object while every band is untouched.

Only master is a special role token

master is the only role the engine treats specially, and aux is just the default a bus gets when it has no role. Any other role string is a generic non-master bus, so a "drum bus" works the same whether its role is submix, subgroup, or group: the token is a label, not a behavior switch. The built-in drumBusSubgroup preset uses subgroup, so if you print it (mixingScenePresetJson('drumBusSubgroup')) you will see "role": "subgroup", not "submix". A misspelt "mastre" is not an error either. That bus becomes an ordinary one, and the master is the bus with id master or an implicit one. The scene still sounds, but not through the bus you configured.

VCA group ​

A VCA (voltage-controlled amplifier) group is one fader that trims several strips at once without re-routing their audio.

FieldTypeMeaning
idstringRequired. Group identifier
gainDbnumberOffset summed into each member's fader, default 0
membersstring[]Strip ids governed by the group

Connection ​

FieldTypeMeaning
sourcestringRequired. Strip or bus id the signal leaves
destinationstringRequired. Strip or bus id the signal enters

Connections are the graph edges. A strip routed to master is { "source": "vocal", "destination": "master" }. A send's bus reaches the master through a connection from the bus (or its return strip) to master. Both ends must name a strip or bus in the document; the mixer build refuses connection references unknown node otherwise.

A strip with no outgoing connection is routed to the master bus automatically when the graph is built. An explicitly declared bus is not: an aux or group bus with no connection out stays unpatched, which is how a scene keeps a bus silent on purpose.

Validating a scene against the schema ​

The scene format is described by a JSON Schema (draft 2020-12) titled MixSceneDocument. It covers the document Mixer.fromSceneJson reads, toSceneJson writes, suggestMixScene returns, and sonare mix --scene loads. The engine's own tests hold the schema to the writer's published field list and validate every built-in preset against it, so a key the writer emits cannot be missing from it.

WherePath
Engine source treeschemas/mixer-scene.schema.json
npm @libraz/libsonareexports subpath @libraz/libsonare/schemas/mixer-scene.schema.json
Python wheellibsonare/schemas/mixer-scene.schema.json inside the installed package
Python sdistschemas/mixer-scene.schema.json

The $id (https://libraz.net/schemas/libsonare/mixer-scene.schema.json) is an identifier, not a download location — point your validator at the installed copy. The @libraz/libsonare-native package and the CMake install do not carry the file; take it from one of the packages above.

Associate a schema with a scene

A scene may carry a "$schema" key for editor discovery. Keys beginning with $ or x- are accepted on every object by the schema and ignored without warnings by the loader. You can also associate the schema through an editor file-match rule or pass it directly to a validator, as below.

typescript
import Ajv from 'ajv/dist/2020';
import schema from '@libraz/libsonare/schemas/mixer-scene.schema.json' with { type: 'json' };

const validate = new Ajv().compile(schema);
const scene = JSON.parse(sceneText);
if (!validate(scene)) {
  throw new Error(JSON.stringify(validate.errors, null, 2));
}
const mixer = Mixer.fromSceneJson(sceneText, 48000, 512);
bash
# npm: ajv-cli, with the draft the schema declares
npx ajv-cli validate --spec=draft2020 \
  -s node_modules/@libraz/libsonare/schemas/mixer-scene.schema.json \
  -d my-scene.json

# Python: check-jsonschema, reading the copy inside the installed wheel
check-jsonschema \
  --schemafile "$(python -c 'from importlib.resources import files; print(files("libsonare") / "schemas/mixer-scene.schema.json")')" \
  my-scene.json
json
{
  "json.schemas": [
    {
      "fileMatch": ["*.scene.json"],
      "url": "./node_modules/@libraz/libsonare/schemas/mixer-scene.schema.json"
    }
  ]
}

Where the schema and the loader disagree ​

The schema is written to be stricter than the loader wherever the loader would otherwise accept a mistake silently. In the other direction, the loader enforces a few bounds the schema leaves open, and the mixer build checks references no schema can express. Validate first, then load, then read sceneWarnings() (or the list Project.setMixerSceneJson returns) — each layer catches what the previous one cannot.

InputSchemaLoader
Unknown or misspelled key (faderDB)Rejected (additionalProperties: false)Loaded without effect and reported in sceneWarnings(); the field keeps its default. The live strip and EQ band setters refuse it
Legacy snake_case key (fader_db, params_json, processor_name, destination_bus_id, vca_groups, …)RejectedAccepted; the writer never emits this spelling
params as an escaped JSON stringRejected (type: object)Accepted as the legacy form
Missing versionRejected (required)Assumed 1
Wrong-typed scalar ("faderDb": "loud", "muted": 1)RejectedIgnored silently; the field keeps its default
pan, dualPanLeft, dualPanRight outside −1 … 1, width outside 0 … 2Rejected (minimum/maximum)Clamped into range
Missing id, processor, destinationBusId, source, destinationRejected (required, minLength: 1)Read as empty and refused later by the mixer build
Non-object entry in strips/buses/vcaGroups/connections/inserts/sends, non-string members entryRejectedSkipped silently
Number outside 32-bit float range (1e40)AcceptedRejected: floating-point field is non-finite or out of float range
Unknown processor id, connection or sidechainKey naming an unknown node, duplicate idsAccepted (not expressible)Refused by the mixer build
destinationBusId naming no declared busAccepted (not expressible)Accepted: the send feeds an implicit aux bus routed to the master, which busCount() does not count

Where both agree, the validator's message simply arrives earlier and names the path. They agree that version must be 1 when present, that slot and timing are "pre"/"post" strings, that sourceLayout and layout are one of the four layout tokens, and that integer fields must be integral. They also share the ranges panMode 0–2, panLaw 0–3, channelDelaySamples 0–192000, and metering.truePeakOversample 1–16.

Built-In Presets ​

PresetIntent
vocalReverbSendVocal strip (EQ + compressor inserts) with a post-fader aux send into a plate-reverb return
drumBusSubgroupKick/snare/overheads into a group bus (role subgroup), made cohesive with parallel compression and tape, ridden by a "drums" VCA
commentaryDuckingHost/guest speech (de-ess + compress) with a music bed ducked via dynamics.sidechainRouter keyed off the host

Discover them at runtime with mixingScenePresetNames() and fetch one with mixingScenePresetJson(name).

A complete, annotated scene ​

This is the actual output of mixingScenePresetJson('vocalReverbSend') (defaults trimmed for readability, so the shortened form still validates). dryWet: 1 keeps the return fully wet; the plate's own default is 0.35, so leaving it out would let dry signal into the return. It shows every relationship: a vocal strip with two pre-fader inserts and a post-fader send, a reverb return strip, two buses, and the connections that wire them to the master.

json
{
  "version": 1,
  "strips": [
    {
      "id": "vocal",
      "faderDb": -3,
      "inserts": [
        { "slot": "pre", "processor": "eq.parametric",
          "params": { "band0.type": 4, "band0.frequencyHz": 80, "band1.frequencyHz": 4000, "band1.gainDb": 2 } },
        { "slot": "pre", "processor": "dynamics.compressor", "params": { "thresholdDb": -18, "ratio": 2.5 } }
      ],
      "sends": [
        { "id": "vocal-to-verb", "destinationBusId": "vocal-verb", "sendDb": -14, "timing": "post" }
      ]
    },
    {
      "id": "vocal-verb-return",
      "faderDb": -10,
      "width": 1.25,
      "inserts": [
        { "slot": "post", "processor": "effects.reverb.plate", "params": { "decaySec": 1.8, "preDelayMs": 25, "dryWet": 1 } }
      ]
    }
  ],
  "buses": [
    { "id": "master",     "role": "master" },
    { "id": "vocal-verb", "role": "aux" }
  ],
  "vcaGroups": [],
  "connections": [
    { "source": "vocal",             "destination": "master" },
    { "source": "vocal-verb",        "destination": "vocal-verb-return" },
    { "source": "vocal-verb-return", "destination": "master" }
  ]
}

Trace the reverb below: one reverb instance, with the dry vocal and the wet return kept as separate paths to master.

Reverb-send signal path
drypost-fader sendwetvocal (strip)vocal-verb (aux bus)vocal-verb-return(plate reverb)master
The vocal strip connects straight to master (dry) and, via a post-fader send, into the vocal-verb aux bus; that bus feeds the vocal-verb-return strip hosting the plate reverb, which returns to master (wet).

The eq.parametric insert uses band-indexed keys

The eq.parametric insert reads band-indexed keys — band{N}.type, band{N}.frequencyHz, band{N}.gainDb, band{N}.q, and the per-band dynamic-EQ fields. In this preset, band0 is an 80 Hz high-pass ("band0.type": 4 is HighPass in the EQ band-type enum) and band1 is a +2 dB presence bell at 4 kHz. This insert is separate from the strip's built-in eq field.

List the full key set with masteringInsertParamNames('eq.parametric'). Keys outside that list (say, a flat highPassHz) load fine but take no effect, and Mixer.sceneWarnings() reports them after the scene loads.

For one-knob tonal moves, simpler inserts remain: eq.tilt (tiltDb, pivotHz) for a broad bright/dark tilt, and spectral.airBand (amount, shelfFrequencyHz) for a high-shelf "air" lift.

Editing and re-saving ​

typescript
const json = mixingScenePresetJson('vocalReverbSend');
const mixer = Mixer.fromSceneJson(json, 48000, 512);

mixer.sceneWarnings();  // [] unread keys (typos) would be listed here, non-fatally

mixer.addSend(0, 'more-verb', 'vocal-verb', -18, 'postFader');  // topology change
mixer.compile();                                                 // rebuild before timing-critical work

const saved = mixer.toSceneJson();   // round-trips back to the same format
python
import libsonare as sonare

scene_json = sonare.mixing_scene_preset_json('vocalReverbSend')
with sonare.Mixer.from_scene_json(scene_json, sample_rate=48000, block_size=512) as mixer:
    mixer.scene_warnings()  # [] unread keys (typos) would be listed here, non-fatally

    mixer.add_send(0, 'more-verb', 'vocal-verb', -18, 'post_fader')  # topology change
    mixer.compile()                                                  # rebuild before timing-critical work

    saved = mixer.to_scene_json()   # round-trips back to the same format
bash
# Export a built-in scene, edit the JSON file, then render it.
sonare mixing-preset --preset vocalReverbSend > my-scene.json
sonare mix --scene my-scene.json --input vocal=vocal.wav -o master.wav

mix --scene with per-strip inputs is Python-CLI only

Rendering a whole scene from a JSON file with one --input per strip is implemented by the Python CLI. The native CLI has no mix command at all: its strip command answers to sonare-cli mix-strip and nothing else, so a script that still calls sonare-cli mix fails as an unknown command. mix-strip itself is on both CLIs, but it is a single-strip, single-input processor with no --scene — use it for quick per-strip checks, not full scene renders. See the channel strip.

When to recompile

Structural edits (adding or removing buses, sends, or connections, or re-routing with setOutputBus) mark the graph dirty and need compile() before the next timing-critical block. Parameter moves (setSendDb / Python set_send_db, setPanLaw), VCA group changes (add/remove/gain — applied live as control-only gain offsets on member strips), and scheduled automation do not need a recompile.