diff --git a/docs/audio-ownership-plan.md b/docs/audio-ownership-plan.md new file mode 100644 index 0000000..84a18cd --- /dev/null +++ b/docs/audio-ownership-plan.md @@ -0,0 +1,630 @@ +# 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 `