# Audio Ownership & Mixer Plan — Routes, Leases, and the Engine-Owned Mixer Follow-up track to `docs/audio-engine-tlc.md` (Part V step 7, "ownership work", deferred there because it needs the rig_builder repo). Drafted 2026-07-14 on `refactor/audio-engine-tlc` after the TLC phases + PR #107 review fixes landed. **Goal**: the audio engine *owns* audio settings, devices, chains, and output. Everything else — bundled screens, plugins, minigames — is a *client* that requests routes and holds leases. One ownership model (leases), one output model (mixer channels), meeting at the route. Substrate this plan builds on (already shipped on the TLC branch): | Shipped | Used here as | |---|---| | `chainGeneration` + mutation serializer | tamper-evident seal under the lease protocol; becomes per-route | | refcounted monitor-mute arbiter | precedent + implementation pattern for lease scopes | | single persistence store (file-backed) | where lease-relevant user prefs (tone engine) live | | `PackedStereoRing` template | the mixer channel's ring | | `RendererBus` (ring + resampler + prime gate + fill clamp + metrics) | becomes `MixerChannel`, instantiated N times | | executor route map + outcome vocabulary + authorization gating | grows into the lease registry | | rebuild barrier + editor try-lock discipline (PR #107) | unchanged; leases sit above it | --- ## 1. Terminology (binding — settle it before any code) | Term | Meaning | Today's artifact | |---|---|---| | **source** | engine-side pool entry (`SourceChain`, max 8): capture binding + detection + its own `SignalChain` | `sources[i]` | | **route** | the executor/lease-level name for a source's chain or a mixer channel; what callers request and hold | route key (`desktop-main`) | | **slot** | a processor position *within* a chain | `ProcessorSlot`, `slotId` | | **lease** | exclusive, revocable authority over one scope (a route's chain, device config, …) | monitor-mute arbiter (single-scope precursor) | | **channel** | a mixer input: ring + gain/mute/meter, owned by the engine, held by a client | `RendererBus` (the only one today) | "Slot" is NOT used for sources/routes anywhere — it already means chain position and overloading it will corrupt every future review. The user-facing "player slot" is the `desktop-main` route bound to source 0. --- ## 2. The lease registry Lives in the **main process** as an extension of `audio-effects-executor.ts` (it already has routes, outcome strings, and authorization gating). Native stays dumb: per-route `chainGeneration` remains the detection layer beneath the polite JS protocol — a lease violation that somehow reaches native is still caught as a foreign write. ### Two primitives, not one The conflict inventory (§6) shows two distinct shapes of contention, so the registry offers two primitives: 1. **Exclusive lease** — conflicting authority; one holder at a time (refusal / takeover semantics as below). For: chains, device config, transport. 2. **Refcounted demand** — *additive* intent ("I need X on") where multiple consumers legitimately overlap; the engine acts while count > 0. For: engine-run/capture, detection arming. This is the monitor-mute arbiter generalized into a registry primitive — same semantics, one implementation instead of one per setting. ### Scopes Exclusive-lease scopes: - `device-config` — device/type/sample-rate/buffer AND input-channel select (one global scope) - `signal-chain:` — a specific route's chain, including its noise gate (per-route) - `playback` — backing transport + playhead authority (one global scope; the playback screen acquires on song load, releases on exit — verified 2026-07-14 that splitscreen followers are transport read-only) - `monitor-state` — already arbitrated; folds into the registry as a scope - `mixer-channel:` — an output channel's gain/mute (per-channel; the holder is whoever requested the channel) Refcounted-demand scopes: - `capture` — "the engine must be running and capturing" - `detection:` — "ML note detection must be armed on this route" Different holders hold different scopes concurrently. The tuner reading pitch holds nothing — reads are never lease-gated, only mutations. ### Holder identity is DERIVED, never declared The main process derives `holderId` from the IPC sender (webContents id + plugin manifest), never from a caller-supplied string. Main-process / engine-internal callers use a fixed enum of well-known synthetic ids (§8.4). Granularity limit: plugins sharing one renderer are distinguished only by capability-layer attribution — see compound identity, §9. Consequences, all load-bearing: - **Death invalidation is automatic**: webContents destroyed → every lease, demand, and channel handle it held is released; reload gets a short grace window instead (§8.2). No heartbeat protocol needed for the realistic failure modes. - No spoofing, no accidental identity collisions between plugins. - `getHolder()` and telemetry name the real owner, which is what makes the takeover UI and field diagnostics honest. ### Layered values (base + override) For settings both the user AND a lease holder legitimately write (first case: the noise gate, §6.2): the user surface writes a persistent **base preference**; the scope's lease holder may set an **override** that lives exactly as long as the lease; release (or revocation, or holder death) restores the base. The monitor-mute arbiter's user-pref-plus-overrides model applied to arbitrary scalars — implemented once in the registry, used by any scope that declares a layered setting. ### API shape (executor-level, contract-snapshotted) ``` acquireLease(scope, holderId, opts) -> { granted | refused(holder, reason) } releaseLease(scope, holderId) getHolder(scope) -> holderId | null events: lease-granted, lease-released, lease-revoked, lease-refused ``` ### Policy decisions (settled in discussion, 2026-07-14) - **Refusal by default.** A second caller is refused while the scope is held. No silent stealing — that recreates today's races with extra steps. - **User-initiated takeover** as the only revocation path for contended scopes (practically: `desktop-main`). The UI offers "take over"; the registry revokes with a `lease-revoked` event so the old holder can degrade gracefully. Per-route leases make contention rare — a refused caller can usually request its OWN route instead of fighting. - **Death-triggered invalidation** — the hard requirement. Leases are tied to observable lifecycles: webContents destroyed (reload = grace window, §8.2), plugin teardown, or executor route release. A wedged/crashed holder must never brick a scope until restart. Pattern precedent: vst-crash-guard sentinels. --- ### Ownership at a glance Every aspect of the engine, its owning authority, and who holds what. "Engine" in the authority column means: the engine is the only mutator; everyone else goes through the named scope/API. Reads (meters, playhead, chain state, detection results) are always free. | Aspect | Authority | Scope / primitive | Typical holder | Everyone else | |---|---|---|---|---| | Devices, types, sample rate, buffer sizes | Engine | `device-config` lease | audio_engine device screen | read-only; route requests | | Input channel select (per source) | Engine | `device-config` lease (per-route later) | device screen | request via route API (6.6) | | Engine run / capture state | Engine | `capture` demand (refcount) | any consumer needing live input (tuner, minigames, notedetect, bongocat) | raw start/stop = device screen only | | Signal chain of a route (slots, params, presets) | Engine | `signal-chain:` lease | tone engine (Rig Builder or native) for `desktop-main`; requester for own routes | refused (`held`); takeover UI on `desktop-main` | | Noise gate (per route) | Engine | layered value on `signal-chain:` | base: user settings UI; override: chain lease holder | refused | | Monitor mute / kill | Engine | `monitor-state` (arbiter: user pref + overrides) | user pref + transient suppressors | via arbiter only | | Master / backing / input gains | Engine | user-scoped, native-clamped (TLC fix) | user UIs | last-writer-wins, sanitized | | Backing transport + playhead | Engine | `playback` lease (global) | playback screen | refused; verifier feed rides the scope's contract (6.5) | | Detection arming (ML/ONNX) | Engine | `detection:` demand (refcount) | notedetect, minigames, strum-fighter — concurrently | reads (`getActiveDetection`) always free | | Verifier offsets (per route) | Engine | route's detection scope | calibration UI of the route holder | plugin-local verifiers unaffected (6.7) | | Mixer: channel lifecycle + audio content | Engine | tier-3 produce handle (§5.1) | the channel's requester | no handle, no writes | | Mixer: channel gain / mute (the fader) | Engine | tier-2 mix control — NOT leased | the user, via any mixer UI (`audio-mix` capability for plugins) | last-writer-wins, native-clamped, event-synced | | Mixer: default channel #0 content | Engine | channel #0 produce handle | juce-audio feeder (feedBack repo) | push refused without handle | | Mixer: which channels StreamSink taps | Engine | structure (tier 4) | stream-settings UI via lease | read-only | | Settings persistence | Main process | file store (single writer since TLC) | audio-bridge | localStorage = migration source only | Rule of thumb encoded above: **content and structure are held; faders and reads are free.** ## 3. Route requests — input AND output through one API Callers never touch source indices (the `stageSlots` fragility lesson: no client-side index coupling). They request a route with properties and get an opaque handle they now hold. ``` requestRoute({ kind: 'input-physical', device, channel }) -> route | refusal requestRoute({ kind: 'input-virtual', midi: true }) -> route | refusal requestRoute({ kind: 'output', label, latencyHint }) -> route | refusal releaseRoute(route) ``` - **input-physical** — a device+channel binding (existing bind rules: pool cap 8 sources, 3 extra devices, duplicate/primary checks). Comes with a leasable signal chain. **Consent-gated**: binding a microphone/interface is a privacy-adjacent user-visible act → flows through the executor's existing authorization gating (`user-action` / `restore-selection`); a plugin cannot silently start capturing. - **input-virtual** — NEW engine capability (`addVirtualSource()`): a pool source with no capture binding, silent input feed, full chain + detection + mixer participation. MIDI reaches its chain via the existing per-slot `queueMidiMessage`. Unlocks: MIDI-driven minigames (VSTi in the chain), metronome, device-setup test tone. - **output** — a mixer channel: ring + gain/mute/meter, no chain. Cheap; granted freely up to a hard cap (~16–32, refusal `no-capacity`); channels silent + unfilled for N min are reaped (`channel-removed`), holder re-requests transparently on next push. - **Composite for free**: an input route's chain output IS a mixer channel; requesting an input route implicitly yields its output channel. Refusal vocabulary (extends the executor's outcome strings): `no-capacity`, `device-unavailable`, `already-bound`, `user-action-required`, `held`. Asymmetry to encode deliberately: output = cheap + consent-free; input = scarce + consent-gated. The device-setup flow creates the physical routes the *user* wires up (`desktop-main` = source 0); plugins request additional routes. --- ## 4. Tone-engine consolidation A "tone engine" is simply **the holder of `signal-chain:desktop-main`**. - New user setting (file-backed store): which provider auto-acquires that lease on session start / song load — Rig Builder, Audio Engine native, (future providers register through a capability). - Everyone else's chain writes are *refused*, not raced. Kills, permanently: rig_builder's transient-kill/`_rbUnmuteTimer` timing hacks, the audio_engine screen's ~30 direct `clearChain` sites, the legacy direct `loadPreset` path (`audio-effects.legacy-native-load`). - **Tone auto-switching moves INTO the tone engine** (decision): switching is a tone-engine responsibility, not a separate service. The audio_engine bundle's `applyToneMappingsNow` / `applyToneAutomationFor` migrate behind the provider interface. - Cross-repo sequencing: executor lease API lands first (this repo), rig_builder migrates second (own repo), legacy path goes log-once- deprecated, then dies. --- ## 5. The engine-owned mixer Every audible thing becomes a channel on one native mixer: ``` Mixer (engine-owned) ├─ guitar buses (input routes' chain outputs — incl. virtual sources) ├─ backing player (engine-internal channel) ├─ default channel #0 (permanent: renderer master via loopback capture — │ every sound that doesn't claim a bespoke channel) ├─ plugin channels (stems, metronome, minigame SFX — requested routes) └─ → device output; StreamSink taps configurable channel subsets ``` - **`MixerChannel` = RendererBus generalized.** The ring, producer-side linear resampler, prime gate, fill clamp, flush flag, and metrics move as-is behind a channel registry; per-channel gain/mute/meter on top. - **Channel #0 is the permanent default, not a compat shim** (decision, 2026-07-14): the `getDisplayMedia` loopback capture keeps feeding it, so any renderer audio that never requests a bespoke channel — legacy plugins, UI sounds, one-off `