From a7432c5312195648ca9af29dc6120e97ba62534a Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 17:23:31 +0000 Subject: [PATCH 01/35] Check the batch 17 stored fader automation specs against main Amend #1646, #1647, #1648, #1649 and #1650 against 31fa33fee: stale anchors, landed dependencies from batches 10-16, decision 17 rules and the #1058 research. Remove the specs of nineteen closed issues (batches 15 and 16). Co-Authored-By: Claude Opus 5.5 --- ...s-latency-from-dropping-during-playback.md | 262 -------- ...red-seek-from-the-browser-engine-module.md | 436 ------------ ...pted-candidate-plan-by-compare-and-swap.md | 620 ------------------ ...ery-seek-on-the-plans-source-read-clock.md | 319 --------- ...and-the-c-abi-growth-rule-in-the-header.md | 253 ------- ...held-source-blocks-apart-from-underruns.md | 199 ------ ...persession-and-add-a-seek-to-audit-capi.md | 262 -------- ...-and-replay-its-next-blocks-for-a-prime.md | 315 --------- ...floors-at-a-host-declared-discontinuity.md | 430 ------------ ...essor-across-a-withdrawn-candidate-plan.md | 416 ------------ ...-read-clock-that-leads-its-render-clock.md | 297 --------- ...rce-admission-for-a-superseding-candida.md | 325 --------- ...uild-the-timeline-consumer-and-producer.md | 350 ---------- ...n-a-timeline-that-carries-like-a-source.md | 461 ------------- .../1642-seek-the-session-from-the-c-abi.md | 602 ----------------- ...ek-the-session-from-the-headless-engine.md | 391 ----------- .../1646-prepare-stored-fader-automation.md | 125 ++++ ...7-render-moving-stored-fader-automation.md | 108 +++ ...assify-and-carry-fader-automation-edits.md | 94 +++ ...ca-offsets-with-stored-fader-automation.md | 71 ++ ...-automated-fader-or-trim-by-a-hand-move.md | 102 +++ ...-report-the-plan-latency-on-the-browser.md | 199 ------ ...eding-candidate-keeps-unchanged-from-p0.md | 216 ------ ...702-model-the-engine-seqlock-under-loom.md | 112 ---- 24 files changed, 500 insertions(+), 6465 deletions(-) delete mode 100644 .github/ISSUE_SPECS/1285-keep-every-nodes-latency-from-dropping-during-playback.md delete mode 100644 .github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md delete mode 100644 .github/ISSUE_SPECS/1310-supersede-an-unadopted-candidate-plan-by-compare-and-swap.md delete mode 100644 .github/ISSUE_SPECS/1316-anchor-every-seek-on-the-plans-source-read-clock.md delete mode 100644 .github/ISSUE_SPECS/1317-document-the-seek-contract-and-the-c-abi-growth-rule-in-the-header.md delete mode 100644 .github/ISSUE_SPECS/1318-report-held-source-blocks-apart-from-underruns.md delete mode 100644 .github/ISSUE_SPECS/1319-test-held-seeks-across-swaps-and-supersession-and-add-a-seek-to-audit-capi.md delete mode 100644 .github/ISSUE_SPECS/1320-let-a-source-consumer-check-and-replay-its-next-blocks-for-a-prime.md delete mode 100644 .github/ISSUE_SPECS/1323-reset-latency-floors-at-a-host-declared-discontinuity.md delete mode 100644 .github/ISSUE_SPECS/1344-prepare-a-successor-across-a-withdrawn-candidate-plan.md delete mode 100644 .github/ISSUE_SPECS/1396-give-a-plan-a-source-read-clock-that-leads-its-render-clock.md delete mode 100644 .github/ISSUE_SPECS/1398-size-the-c-abis-plan-capacities-and-resource-admission-for-a-superseding-candida.md delete mode 100644 .github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md delete mode 100644 .github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md delete mode 100644 .github/ISSUE_SPECS/1642-seek-the-session-from-the-c-abi.md delete mode 100644 .github/ISSUE_SPECS/1643-seek-the-session-from-the-headless-engine.md delete mode 100644 .github/ISSUE_SPECS/1688-report-the-plan-latency-on-the-browser.md delete mode 100644 .github/ISSUE_SPECS/1698-carry-an-owner-a-superseding-candidate-keeps-unchanged-from-p0.md delete mode 100644 .github/ISSUE_SPECS/1702-model-the-engine-seqlock-under-loom.md diff --git a/.github/ISSUE_SPECS/1285-keep-every-nodes-latency-from-dropping-during-playback.md b/.github/ISSUE_SPECS/1285-keep-every-nodes-latency-from-dropping-during-playback.md deleted file mode 100644 index 122a57aab..000000000 --- a/.github/ISSUE_SPECS/1285-keep-every-nodes-latency-from-dropping-during-playback.md +++ /dev/null @@ -1,262 +0,0 @@ -# Keep every node's latency from dropping during playback - -Stream A of decision 15 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`, D15-8). -Slice 15 of *Swap a rebuilt plan without an audio gap* (#1269). Code anchors verified on `main` at -`6fb211594`. - -## Product outcome - -Removing a latent effect (a limiter), or the track that holds it, while audio plays no longer moves -the rest of the mix in time, at the output or inside a submix. The successor keeps every surviving -node's input timing, so every other path keeps its compensation and continues bit for bit. The -lower latency comes back at the next discontinuity the host declares, a stop or a seek of every -source (D15-8, *Reset latency floors at a host-declared discontinuity*, #1323). There is no -permanent latency reserve. - -## Context - -- PDC computes timings in `timings` (`crates/graph-compiler/src/pdc.rs:38-160`, called from - `crates/graph-compiler/src/compile.rs:450`). For each node in schedule order: - - `max` is the latest incoming arrival (`:61-65`); - - every incoming edge is delayed by `max - source` (`:66-88`); - - the node's arrival is `max + latency`, and the output latency is the `Output` node's arrival - (`:145-149`). -- When a latent effect is removed, `max` drops at every node downstream of it, so every - compensation line into those nodes shortens. With the head-aligned copy of *Carry compensation - lines across a plan swap* (#1283, D4), every unchanged path into them would then skip that many - samples. -- The output latency is reported as `HostPrepareReport::latency_samples` - (`crates/host-core/src/prepare.rs:224`, filled at `:1841`) and as the C ABI's plan resource - report (`crates/control-plane/src/compile.rs:664`). -- VST3 notes that a plug-in latency change may interrupt playback while the host recomputes delay - compensation (`IAudioProcessor::getLatencySamples`). Floors remove the decrease case. -- The full carry comes from *Carry strip delay lines and live send ramps across a plan swap* - (#1284). - -## Decisions frozen for this slice - -- **D1. Floors.** The graph compile request (`GraphBuiltinsCompileRequest`, - `crates/graph-compiler/src/lib.rs:42`) takes an optional map from `GraphNodeId` to an input - floor, empty by default. In `timings`, a node's `max` is `max(latest incoming arrival, floor)`, - and every incoming edge is compensated to it. With no floors, neither the program nor its bytes - change. -- **D2. Who sets them.** The inventory records each node's floored `max` at preparation. A - successor is compiled with a floor for every node present in both plans, equal to the - predecessor's recorded `max` for that node. Two cases compile with no floors: - - a first compile, a fresh C ABI `compile_session` and a browser boot; - - a successor prepared at a declared discontinuity (#1323). - - Floors therefore last until the next declared discontinuity, never for the session's life. - *Grow latency during playback by adopting a primed warm successor* (#1287) adds its own `P` on - top of these floors, on carried nodes only, through the same map. -- **D3. A path whose own latency dropped.** A floored single-input node whose upstream latency - dropped gains a new compensation line, which starts at rest. That path's strip lost a latent - effect, so it is already in the restart set (#1279 D1) and gets the duck-swap of *Duck-swap a - strip whose state cannot continue across a plan swap* (#1324). -- **D4. Report.** `latency_samples` reports the floored output arrival, which is the real one. -- **D5. Growth is not handled here.** A node whose natural `max` exceeds its floor grows. #1287's - warm successor makes that exact, and its counted fallbacks are the documented transition (D15-8). -- **D6. Documentation.** In the header comment and `docs/C_ABI_V1_QUALIFICATION.md`: no node's - latency, the output's included, drops across a structural transaction, and it returns to its - natural value at the next declared discontinuity (#1323). - -## Deliverables - -1. D1 in `crates/graph-compiler` (the request field and `timings`), plumbed through the builtins - graph compile request. -2. D2-D4 in `crates/host-core`: the inventory field, successor preparation and the report. Also a - `test-support` constructor of an inventory that carries only floors, so a test can compile a - fresh reference with the same floors. -3. D6. - -## Authorized paths - -- `crates/graph-compiler/src/pdc.rs`, `crates/graph-compiler/src/compile.rs`, - `crates/graph-compiler/src/lib.rs`, `crates/graph-compiler/tests/` -- `crates/builtins-compiler/src/lib.rs` (request plumbing only) -- `crates/host-core/src/prepare.rs`, `crates/host-core/tests/successor_swap.rs` -- `crates/capi/src/runtime/tests.rs`, `crates/capi/include/miso_engine_v1.h` (comments only), - `docs/C_ABI_V1_QUALIFICATION.md`. The capi files are stream B's, and root orders the merge. - -## Non-goals - -- No lead floors and no warm successor (#1287), and no latency reserve (rejected by D15-8). -- No discontinuity declaration (#1323). - -## Objective gates - -1. **Gap-free acceptance, output.** Session A has three tracks, the first with a true-peak limiter - insert. Session B removes the first track. The swapped run (swap after block 6) is bit-identical - in every block to a fresh B compiled with A's floors and fed the same PCM from frame 0, at all - four launch rates. Without floors, the remaining tracks jump by the limiter's latency at block 7. -2. **Gap-free acceptance, inside a submix.** Two tracks feed a submix, one through a limiter - insert, and B removes the limiter's track. The other track and everything downstream are - bit-identical to the floored reference. -3. **Through the C ABI.** Gate 1's edit as a `RemoveTrack` transaction: - `miso_engine_v1_plan_resources` reports an unchanged `latency_samples` after the swap, and the - output equals gate 1's reference. -4. **No change without floors.** These keep their digests: `cargo test --locked -p graph-compiler` - (the graph fixture corpus), `bash scripts/check-graph-determinism.sh`, and - `cargo test --locked -p console-workload`. -5. **Floors chain.** A second removal after gate 1's swap keeps the first floors: the inventory - records floored values, not natural ones. Output latency is still A's. -6. Commands: - - `cargo test --locked -p graph-compiler -p builtins-compiler -p host-core -p capi --features builtins-compiler/test-support,host-core/test-support` - - `bash scripts/check-capi-abi.sh`, `bash scripts/check-workspace-policy.sh`, - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --no-deps` - -## Test value - -- Gate 2: floors applied only at the output leave a submix's paths skipping. It turns red. -- Gate 3: a report that keeps the natural latency while the plan renders the floored one turns it - red. -- Gate 4: floors that change the program when absent turn it red. -- Gate 5: an inventory that records the natural `max` drops latency at the second edit. It turns - red. -- Superseded: the former gate 5 (growth pinned as a gap of `Δ` zeros) is not written. #1287's - warm-successor gates own growth. - -## Dependencies - -- *Carry strip delay lines and live send ramps across a plan swap* (#1284). - -## Amendment (controls coordinator, batch 15 check, 2026-10-15) - -Checked against `228fca26c`. - -**Moved anchors.** -- `timings` is `crates/graph-compiler/src/pdc.rs:37-160`. `max` is `:61-65`, the edge loop - `:66-99`, the arrival `max + latency` `:100-104`, the output latency `:143-150`. It is called - from `crates/graph-compiler/src/compile.rs:422`, inside `compile_with_builtins` (`:78`). -- `GraphBuiltinsCompileRequest` is `crates/graph-compiler/src/lib.rs:41`. Its fields are - `plan_id`, `effects`, `builtins`, `caps` and `dispatch`. No floor field exists yet. -- `HostPrepareReport::latency_samples` is `crates/host-core/src/prepare.rs:273`, filled at - `:3134`. The C ABI copies it at `crates/control-plane/src/compile.rs:1003` into - `PlanResources::latency_samples` (`crates/control-plane/src/adapter.rs:104`). The ABI field is - `crates/capi/src/abi.rs:283`. -- The inventory is `PlanStateInventory` (`prepare.rs:711`). It already holds the compensation - lines (`delay_lines`, #1283 D1), the strip delays and live sends (#1284) and the restart set - `restarted` (#1277 D6, `prepare.rs:744`; read by `restarted_strips`, `:688`). D3's "restart set - (#1279 D1)" is this field now. -- Successors are prepared by the control plane: `prepare_runtime` - (`crates/control-plane/src/compile.rs:901`) is called with a `SuccessorBase` at - `crates/control-plane/src/control.rs:1662-1683`. `SuccessorBase` is `prepare.rs:1070`. The floors - travel in the inventory, so D2 needs no control-plane change. -- The header's latency text is `crates/capi/include/miso_engine_v1.h:54` and `:81` (D6's place). - -**Authorized paths.** -- `crates/builtins-compiler` does not build the graph request. The one product site is - `crates/host-core/src/prepare.rs:2466`. Deliverable 1's "plumbed through the builtins graph - compile request" means that site. `crates/builtins-compiler/src/lib.rs` leaves the list. -- The request is a struct literal in 21 files. A new field breaks each one. Outside the list are - `crates/graph-compiler/src/bin/graph_fixture.rs`, `crates/host-core/tests/live_routes.rs`, - `route_mute.rs`, `submix_strip.rs`, `tools/audit/src/builtins_graph.rs`, `fixture_builtins.rs`, - `graph.rs` and `tools/console-workload/src/lib.rs`. Proposed: add them for the neutral field only - (an empty floor map). Root confirms at filing. -- **B15-R9, root decision under owner delegation (2026-10-15): the eight files above are allowed, for a neutral-field-only - addition to the graph request struct literal.** No other edit to them is authorized. - -**Decisions.** -- D1-D6 hold against the code and against decision 17. Decision 17 does not touch latency (C11 - keeps the render rules), and a floor is not a control. -- Both hosts: since #1381a the browser runs `control_plane::SessionState` - (`hosts/host-web/src/lib.rs:6-16`), so a browser rebuild gets floors through the same code. A - browser boot is still a no-floor case. -- The browser reports no plan latency. Decision 17 C13 lists "plan latency and tail" as a one-host - difference for root to file; no issue is filed yet. So D4 and gate 3 cover the C ABI only. -- Empty boot (#1682): an empty plan shares only the output node with its successor, at arrival 0. - Its floor changes nothing. -- Stored automation: #1637's node time is `τ(r) = timeline(r + ΣP - a(n))`. With floors, `a(n)` - must be the floored arrival, the one the plan renders. #1647 (open) reads `a(n)`. Whichever of - #1285 and #1647 lands second makes it read the floored value. - -**Dependencies.** -- #1284 is closed. #1283, #1279 and #1277 are closed. -- Batch order (root, 2026-10-15): batch 15a is the controls lane, #1344, #1398 and #1310. - #1324 left batch 15a (root ruling, 2026-10-15); it lands in a later batch after #1288, #1391, - #1363 and #1325. The rest of batch 15 follows. #1324 is not ordered before this slice. This - slice keeps its no-duck fallback until #1324 lands: a restarted strip starts at rest with no - duck. -- #1324 is open and not in batch 15/16. D3's duck-swap needs it. Until it lands, a restarted - strip starts at rest with no duck. -- #1287 is open. D2 names it as later work only. #1323 is in the batch. - -**Gates and commands.** -- Every named file and script exists: `crates/host-core/tests/successor_swap.rs`, - `crates/capi/src/runtime/tests.rs`, `docs/C_ABI_V1_QUALIFICATION.md`, - `scripts/check-graph-determinism.sh`, `scripts/check-capi-abi.sh`, - `scripts/check-workspace-policy.sh`, `tools/console-workload`. -- Gate 6's command is valid. Its features exist. - -## Amendment (controls coordinator, batch 15a fold, 2026-10-16) - -- **Browser latency.** "No issue is filed yet" (batch-15 amendment, Decisions) is void. It is - #1688 (*Report the plan latency on the browser*), which lands after this slice and adds the - browser's parity row on the floored latency. D4 and gate 3 still cover the C ABI only here. -- **Anchor.** The `restarted` field of `PlanStateInventory` is `prepare.rs:750` at `228fca26c` - (the batch-15 amendment says `:744`). On `main` at `c5b5c88b2` it is `:757`. - -## Decision record: #1285 (batch 15b) - -**What shipped.** -- D1: `GraphBuiltinsCompileRequest::input_floors` (`BTreeMap`, - empty by default). `pdc::timings` takes `max = max(latest incoming arrival, floor)` and - compensates every incoming edge to it. A floor that names no node of the graph is ignored, so the - successor's map can be the predecessor's arrivals as they are ("every node present in both - plans"). `GraphCompileReport::input_arrivals` reports each node's floored `max` where it is - nonzero, sorted by node ID (a missing node has arrival 0, and a 0 floor bounds nothing). -- D2: `PlanStateInventory` holds `input_floors` (from `input_arrivals`, so floored values) and - shows them through `input_floors()`. `retained_bytes` counts them. A preparation with a - `SuccessorBase` passes them as the request's floors. A first plan has none. Across a withdrawn - candidate, `across_withdrawn` clones P0's inventory, so B gets P0's floors (render runs P0). - The discontinuity case (#1323) is not built yet, so every successor floors for now. -- D3: no duck. A restarted strip starts at rest with no duck until #1324 lands, as the batch 15 - amendment says. -- D4: `latency_samples` is the floored output arrival, because PDC's output arrival includes the - floor. No extra code was needed. -- D6: a header paragraph "Latency across a replacement (issue 1285)" and - `docs/C_ABI_V1_QUALIFICATION.md` § "Latency floors across a structural transaction (#1285)". - Both say the lower latency comes back with a fresh compile, or at #1323's discontinuity once it - exists. They do not claim a mechanism that is not built. -- Test support: `PlanStateInventory::test_only_floors_only` (feature `test-support`). -- Both hosts run this one core path (control-plane -> host-core). No host adapter changed. - -**Departures.** -- Gates 1 and 2 mute `eq0` in A. A's blocks before the swap then equal B's, so the test can - compare "every block". An audible `eq0` in A differs from B before the swap by design. -- Gate 3's reference is the same `RemoveTrack` transaction committed before the first block. Its - successor is compiled with A's floors and adopts at frame 0 with all state at rest, so it is B - compiled with A's floors and rendered from frame 0. A test-support inventory reference needs - `host-core/test-support` in capi's dev-dependencies, and `crates/capi/Cargo.toml` is not an - authorized path. -- Mechanical neutral field (`input_floors: Default::default()`) in the 8 B15-R9 files and in the - authorized graph-compiler tests. `crates/builtins-compiler` is not touched. -- Fixtures: none regenerated. With no floors the compiled output does not change. - -**Gates.** -- Gate 1: `host-core` `successor_swap::removing_a_limited_track_keeps_the_output_timing_at_every_rate_and_width` - (4 rates, both widths where built). Red when successors get no floors: latency 0 against 447 at - 44.1 kHz. -- Gate 2: `...::removing_a_limited_track_keeps_the_submix_timing_at_every_rate_and_width`. Red - when floors go only to the output: it first differs at the swap block (6). -- Gate 3: `capi` `runtime::tests::removing_a_limited_track_keeps_the_plan_latency`. Red when - successors get no floors: latency 0 against 486. -- Gate 4: `cargo test -p graph-compiler` (fixture corpus) ok; `check-graph-determinism.sh` PASS - 100/100; `cargo test -p console-workload` ok; `check-builtins-fixtures.sh` ok (50 files). -- Gate 5: `...::a_second_removal_keeps_the_first_floors`. Red when the inventory records the - natural `max`: C's latency is 0 against 486. -- Supersession (added): `...::a_replacement_across_a_withdrawn_candidate_keeps_the_running_floors`. - A adds two limiters (latency 972). B across withdrawn A must keep P0's latency (486). Red when - the base takes A's floors (972) or drops them (0). -- Unit: `graph-compiler` `tests::timing_compensates_every_incoming_edge_to_a_floor`. -- Gate 6 command: all suites ok. `check-capi-abi.sh` ok, `check-workspace-policy.sh` ok, - clippy (touched crates) ok, rustdoc ok, `realtime-policy` ok. - -**Measured.** `audit capi`: allocations 0, deallocations 0, pcm_digest `4f52089e4a8cf83c` -(unchanged). No resource ceiling was re-pinned. - -## Amendment (controls coordinator, batch 15b fold, 2026-10-16) - -- The note above that the discontinuity case (#1323) is not built yet is superseded: #1323 shipped - it in the same batch (`SessionState::declare_discontinuity`, both hosts). diff --git a/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md b/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md deleted file mode 100644 index 3ccc2f102..000000000 --- a/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md +++ /dev/null @@ -1,436 +0,0 @@ -# Export transaction apply and anchored seek from the browser engine module - -Stream H(c) of decision 15 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`, D15-10, D15-11, D15-12, D15-17). -Formerly slice B4 of *Swap a rebuilt plan without an audio gap* (#1269). -Code anchors verified on `main` at `6fb211594`. - -## Product outcome - -The shipped engine module exports the browser's one edit API to its control half: apply a session -transaction, read the outcome `{result, revision, path}` with its diagnostic, and read the -committed document. It also exports the anchored seek and the per-source seek report. Every -published mirror of the export list (ABI layout, SDK, checkers) follows. Decision 15 D15-11 -supersedes the old `replace_document_ptr` / `replace` pair. The document convenience is -*Diff a replacement document against the committed model and export replace from the browser engine -module* (#1386), which uses this slice's staging and outcome record. - -## Context - -- Today `miso_engine_web_v1_document_ptr` refuses while a host is live - (`hosts/host-web/src/ffi.rs:3500-3511`), so no export takes an edit while audio plays. A live - host's diagnostic is `AudioWorkletEngineHost::diagnostic` (`hosts/host-web/src/lib.rs:2872`), read - through `miso_engine_web_v1_buffer_ptr(handle, BUFFER_DIAGNOSTIC)` (`ffi.rs:599`). -- `miso_engine_web_v1_source_seek` (`ffi.rs:3789`) takes the source ID through the - `BUFFER_SOURCE_ID` staging. Its host method `seek_source` (`lib.rs:3175`) calls - `plan.prepare_source_seek` after the producer's seek (`lib.rs:3193-3203`). There is no browser - `seek_at` export. -- **The #1293 D4 trap** (adversary round 1, section D): an anchored-seek export copied from the - plain one would report every accepted future anchor as `RESULT_INTERNAL`, because - `PcmSourceConsumer::prepare_seek` returns `false` while an anchored seek is held - (`crates/source/src/lib.rs:1043-1065`). Its fix and regression test belong to *Test held seeks - across swaps and supersession, and add a seek to audit capi* (#1319). This slice does not - duplicate them. -- On the C ABI the anchored seek is a control-thread call on the source producer only - (`crates/control-plane/src/control.rs:1671`); *Extract the C ABI control plane into a portable - crate both hosts call* (#1309) moves it into the shared crate. -- The export list is published and checked in several places: `scripts/check-web-audioworklet.sh` - (`expected_exports`, `:203`), `tools/parameter-metadata/src/abi_layout.rs` (`EXPORTS`, `:135`), - `scripts/check-abi-layout-v1.py` (e.g. `:191`; run by `check-web-audioworklet.sh:562`), - `sdk/assets/miso-engine-v1-abi-layout.json`, `sdk/src/generated/abi.ts`, and - `scripts/check-sdk-generated.sh`. -- *Replace the running browser session in the Rust host* (#1290) gives the control half - `apply(transaction) -> Result<{revision, path}, refusal>` (its D1); in `single` mode it runs on - the worklet thread (its D6). -- *Run the browser control plane in a Worker and keep the AudioWorklet render-only* (#1332) sets the - modes: `worker` (control half in the Worker) and `single` (one instance in the worklet). - *Swap and retire browser plans through the Worker's service loop* (#1381) puts - `control_plane::SessionState` in the Worker and boots it with the producers there (its D1), - preparing through the browser's preparer from *Prepare through an adapter-supplied preparer in - the control-plane crate* (#1400). - *Admit browser live edits in the Worker through the committed model* (#1562) moves live commands - there. -- **Source producers.** #1332 D3 item 6 keeps source submission and seeks on the worklet thread, - but #1290 D4 moves the persisting producers into the candidate on the control thread at commit, - and #1381 D1 hands the Worker's producers to `SessionState`, which prepares through #1400's - preparer. Only the C ABI shape is consistent: producers belong to the control half. *Move - browser source submission and seeks into the Worker* (#1387) makes that move; this slice needs - it (see Dependencies). -- The path codes come from *Report each transaction's edit path in its response* (#1313). - Completion is the watermark, in the browser status (*Publish the applied-revision watermark in - the browser status*, #1349); it is not part of this record. The control half's two other exports, - `miso_engine_web_v1_service(handle)` (the service step) and the watermark-and-counters export, - come from #1381 with their mirrors; this slice adds neither. -- *Anchor every seek on the plan's source-read clock* (#1316) adds a per-ring seek report - (`generation`, `first_sample`, `source_frame`, cumulative underrun and held frames) behind - `SourceControlSet::seek_report(id)` and assigns its browser export to this slice. - -## Decisions frozen for this slice - -- **D1. Staging.** `miso_engine_web_v1_edit_ptr(handle, len) -> u32` returns a pointer to a - staging buffer for one transaction frame or one document (#1386) of `len` bytes, bounded by - `MAXIMUM_DOCUMENT_BYTES`, or 0 with a typed result. It may allocate. Every export in this slice - runs on the control half (the Worker in `worker` mode, the worklet in `single` mode). On an - instance that holds no control half each refuses with `RESULT_REFUSED_LIFECYCLE` (a pointer - export returns 0). -- **D2. Apply.** `miso_engine_web_v1_apply(handle, len) -> u32` decodes the staged bytes as one - `SESSION_TRANSACTION_APPLY` frame (`crates/protocol/src/session_wire.rs`; the bytes the C ABI - accepts) and runs #1290's `apply`. It returns as soon as the transaction commits (D15-17) and - never waits for render, a swap or a warm adoption. -- **D3. Outcome.** `miso_engine_web_v1_edit_outcome_ptr(handle) -> u32` points at a fixed - `#[repr(C)]` record rewritten by every apply (and by #1386's replace): `struct_size u32`, - `abi_version u32`, `result u32`, `path u32` (#1313's codes), `revision u64` (the committed - revision; unchanged on a refusal). A refusal also writes the host diagnostic and leaves the - committed model, the revision, the running plan and the sticky state unchanged. -- **D4. Committed document.** `miso_engine_web_v1_committed_document_ptr(handle) -> u32` and - `miso_engine_web_v1_committed_document_bytes(handle) -> u32` expose the canonical snapshot of the - committed model (`SessionStore::canonical_snapshot`, `crates/protocol/src/model.rs:900`), valid - until the next commit. -- **D5. Anchored seek.** `miso_engine_web_v1_source_seek_at(handle, source_id_bytes, generation, - source_frame, anchor_sample) -> u32`, the source ID through `BUFFER_SOURCE_ID`, with the plain - seek's argument checks. It calls the shared crate's producer-side `seek_at` (#1309), never - `plan.prepare_source_seek`. A held seek's result codes are those #1319 fixes; an accepted future - anchor returns `RESULT_OK`; an anchor that is not a quantum multiple returns - `RESULT_INVALID_ARGUMENT`. -- **D6. Seek report.** `miso_engine_web_v1_source_seek_report(handle, source_id_bytes) -> u32` - copies #1316's report for the staged source ID into a fixed `#[repr(C)]` record at - `miso_engine_web_v1_source_seek_report_ptr(handle)`: `struct_size u32`, `reserved0 u32`, - `generation u64`, `first_sample u64`, `source_frame u64`, `underrun_frames u64`, - `held_frames u64`. A busy read returns `RESULT_BACKPRESSURE`; an unknown source - `RESULT_INVALID_ARGUMENT`. -- **D7. Mirrors.** Add the eight exports to every list in the context, regenerate the SDK mirrors - with the repository's generators, and keep every checker's self-test passing. Both records join - the ABI layout document with their field offsets. - -## Deliverables - -1. D1-D6 in `hosts/host-web/src/ffi.rs` and `lib.rs`. -2. D7 in the checkers, the parameter-metadata generator and the generated SDK files. -3. Native tests in `hosts/host-web/src/tests.rs`. - -## Authorized paths - -- `hosts/host-web/src/ffi.rs`, `hosts/host-web/src/lib.rs`, `hosts/host-web/src/tests.rs` -- `scripts/check-web-audioworklet.sh` (export list only), `scripts/check-abi-layout-v1.py` and its - self-test fixture, `tools/parameter-metadata/src/abi_layout.rs` -- `sdk/assets/miso-engine-v1-abi-layout.json`, `sdk/src/generated/abi.ts`, - `scripts/check-sdk-generated.sh` - -## Non-goals - -- No document diff or `replace` export (#1386). No control-plane logic (#1290, #1309). No - service or watermark export: `miso_engine_web_v1_service(handle)` and the watermark-and-counters - export are #1381's, and the status words are #1349's. No Worker or worklet JavaScript (*Send a session - transaction to the browser control plane*, #1294). No SDK API (*Apply session transactions from - the browser SDK*, #1296). -- No change to the plain seek export's semantics; its anchoring and report are #1316's. - -## Objective gates - -1. **Apply through the exports.** A native test calls the `extern "C"` functions on the control - half: boot A (four tracks), stage and apply a transaction that adds a muted track with an EQ - insert. The outcome record reads `RESULT_OK`, path `rebuild`, revision +1, and the - committed-document export reads the canonical text of A plus that track. A transaction that - changes only one fader reads path `live`. -2. **Refusals change nothing.** A malformed frame, a stale expected revision and a frame over - `MAXIMUM_DOCUMENT_BYTES` each refuse with their typed result and a non-empty diagnostic; the - outcome's revision, the committed document and the next rendered blocks equal a run without the - call. -3. **Anchored seek.** `source_seek_at` with an anchor two quanta ahead returns `RESULT_OK`; the - source renders silence until the anchor and frame F from it on. An unaligned anchor returns - `RESULT_INVALID_ARGUMENT`. -4. **Seek report.** After gate 3's seek renders, `source_seek_report` reads `(generation, - first_sample = anchor, source_frame = F)`; an unknown source ID returns - `RESULT_INVALID_ARGUMENT`. -5. **Role.** Each export called on an instance without a control half refuses with - `RESULT_REFUSED_LIFECYCLE` and changes nothing. -6. **Mirrors agree.** `python3 -B scripts/check-abi-layout-v1.py` with its self-test, - `bash scripts/check-sdk-generated.sh ` and the parameter-metadata `--check` pass, - and each fails if one list omits one export. -7. Commands: - - `cargo test --locked -p host-web --features host-web/test-support` - - `rm -rf target/ci/h1293 && mkdir -p target/ci/h1293/a target/ci/h1293/n && bash scripts/build-web-audioworklet.sh --named-twin target/ci/h1293/n target/ci/h1293/a && bash scripts/check-web-audioworklet.sh target/ci/h1293/a target/ci/h1293/n/miso-engine-v1-audio-worklet.simd128.named.wasm` - - `bash scripts/check-sdk-generated.sh target/ci/h1293/a`, `bash scripts/test-web-audioworklet.sh` - - `cargo clippy --locked --workspace --all-targets -- -D warnings`, `cargo fmt --all -- --check`, - `bash scripts/check-workspace-policy.sh` - - The shipped artifact changes: report its new digest and size in the PR. - -## Test value - -- Gate 1: an apply export that waits for render or writes the wrong transaction's path, or a - committed-document export that still serves the booted text (the SDK would bind live controls to - a session that no longer runs), turns it red. No Rust-level test reads these records. -- Gate 2: a refusal that wrote the outcome's revision or moved the committed model turns it red. -- Gate 3: an export wired through `prepare_source_seek` (the D4 trap's shape) returns - `RESULT_INTERNAL` for the future anchor. #1319 owns the shared-path regression; this gate defends - the browser wiring only. -- Gate 4: an export that reads another source's record, or its words in the wrong order, - misaligns every stem the SDK starts from it (#1297). -- Gate 5: an edit export that runs on the render half would allocate on the audio thread. -- Gate 6: a mirror that misses one export lets the SDK call a name the module lacks. - -## Dependencies - -- *Replace the running browser session in the Rust host* (#1290). -- *Run the browser control plane in a Worker and keep the AudioWorklet render-only* (#1332). -- *Swap and retire browser plans through the Worker's service loop* (#1381). -- *Extract the C ABI control plane into a portable crate both hosts call* (#1309). -- *Report each transaction's edit path in its response* (#1313). -- *Anchor every seek on the plan's source-read clock* (#1316). -- *Test held seeks across swaps and supersession, and add a seek to audit capi* (#1319). -- *Move browser source submission and seeks into the Worker* (#1387). - -**Decision 17 amendment (2026-10-13).** #1562 adds one generic command-frame entry on the control half (`miso_engine_web_v1_control_request_ptr`, `_control_command`, `_control_response_ptr`, `_control_response_bytes`) that runs `SessionState::command` for any frame, transactions included. D2 (`miso_engine_web_v1_apply`) and D3 (the outcome record) are dropped: a transaction is applied through that entry and its outcome is the response frame (revision, `edit_path`, diagnostics). D4-D7 (committed document, anchored seek, seek report, mirrors) and D1's staging for #1386's document replace stay. - -**Decision 17 amendment (2026-10-13), second line.** D4 is withdrawn too: the committed document reaches the browser as `SESSION_SNAPSHOT_GET` frames through #1562's generic command entry, with the same bytes as on the C ABI, and the SDK reads it with `engine.committedDocument()` (#1571). No `committed_document_ptr` or `committed_document_bytes` export is added. D5-D7 (the anchored seek, the seek report and the mirrors) stay. - -## Amendment (controls coordinator, batch 15 check, 2026-10-15) - -Checked against `228fca26c`. - -**Moved anchors.** -- `miso_engine_web_v1_document_ptr` is `hosts/host-web/src/ffi.rs:2959`; its live refusal is - `:2960-2970`. -- `AudioWorkletEngineHost::diagnostic` is `hosts/host-web/src/lib.rs:3175`. - `miso_engine_web_v1_buffer_ptr` is `ffi.rs:2996`, and its `BUFFER_DIAGNOSTIC` arm `:723`. -- `miso_engine_web_v1_source_seek` is `ffi.rs:3251`. Its host method `seek_source` is - `lib.rs:3322`. It calls `ready.control.seek_unserviced` and then `plan.prepare_source_seek` - (`:3340-3352`). -- `PcmSourceConsumer::prepare_seek` is `crates/source/src/lib.rs:1049` (doc `:1040-1048`). The - D4 trap still holds. -- The producer-side anchored seek is `SessionState::seek_at`, - `crates/control-plane/src/control.rs:3028` (#1309 moved it). It runs the service step first. -- The mirrors: `expected_exports` is `scripts/check-web-audioworklet.sh:274`, and its call of - `check-abi-layout-v1.py` is `:635`. `EXPORTS` is `tools/parameter-metadata/src/abi_layout.rs:125` - (102 names) and `scripts/check-abi-layout-v1.py:120`. -- #1562's generic entry exists: `control_request_ptr` (`ffi.rs:3347`), `control_command` - (`:3366`), `control_response_ptr` (`:3399`) and `control_response_bytes` (`:3412`). `service` is - `:3336`. - -**The control half today.** The browser runs one instance. Since #1381a it holds -`control_plane::SessionState` (`hosts/host-web/src/lib.rs:6-16`). Today, "no control half" means -a host that is not ready: `control_event` returns `RESULT_REFUSED_LIFECYCLE` for it -(`lib.rs:2397-2415`). That code equals `RESULT_WRONG_STATE` (`lib.rs:146`). Gate 5 uses that case -until #1332 adds `worker` mode. - -**After the two decision 17 amendments.** -- D2, D3 and D4 are dropped. Gates 1 and 2 test only those, so they go too. #1562's gates cover - apply through the generic entry. -- Deliverable 1 is now D1, D5 and D6. D7's "eight exports" is now four: `edit_ptr`, - `source_seek_at`, `source_seek_report` and `source_seek_report_ptr`. The one record is D6's. If - D1 goes (below), three. -- D5 and D6 hold against decision 17. The C ABI has both as C exports: - `miso_engine_v1_source_seek_at` (header `:425`) and #1316 D6's - `miso_engine_v1_source_seek_report`. So these exports put the same function on the second host - (C1). They are not a browser-only feature, and source transport is not a control. - -**B15-R8, root decision under owner delegation (2026-10-15): drop D1 from this slice. Re-scope #1386 under C1.** #1386's diff -goes in `control-plane` as a protocol frame through `submit_command` and `control_command` on both -hosts. Its staging is `control_request_ptr`, and its outcome is the response frame. #1386 and #1643 -record this in their own amendments. Deliverable 1 is now D5 and D6. D7's exports are three: -`source_seek_at`, `source_seek_report` and `source_seek_report_ptr`. Only D1's staging export -`edit_ptr` goes. D1's other rule stays: each remaining export runs on the control half and refuses -with `RESULT_REFUSED_LIFECYCLE` (a pointer export returns 0) on an instance with no control half. -The finding follows. D1's staging stays only for #1386's document replace. But #1386 (open) also -uses D3's outcome record, which is dropped. And #1386's diff sits in `hosts/host-web` with no C ABI -form, which is a browser-only feature under decision 17 C1. The generic entry's staging -(`control_request_ptr`, bounded by the boot's `maximum_control_frame_bytes`) already carries a -frame. Proposed: drop D1 from this slice. Re-scope #1386 under C1: the diff goes in the core -(`control-plane`) as a protocol frame through `submit_command` and `control_command` on both hosts. -Its staging is `control_request_ptr`, and its outcome is the response frame. - -**Hazard on D5.** The SDK PCM feed calls the plain seek from `process()` too: `applySharedSeek` is -"shared by render and the between-block control handler" -(`sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js:465-470`). That is why `seek_source` -uses `seek_unserviced`. `SessionState::seek_at` runs the service step, which `process()` forbids -(`control.rs:2983-2990`). D1's rule (control half only) holds for `source_seek_at`, so the SDK -calls it from the message handler only (#1294, #1297). A render-side caller would need a -`seek_at_unserviced` in `crates/control-plane`, which is outside this slice's paths. - -**Dependencies.** -- Closed: #1309, #1313, #1381 (as #1381a) and #1562. -- In the batch: #1316 and #1319. -- #1290 (open) was needed only for D2. #1313 was needed only for D3. Proposed: remove both. -- #1332 and #1387 are open and not in batch 15/16. In `single` mode, #1381a already routes every - source export through `SessionState`, and #1387's amendment only moves that routing to the - Worker. Proposed: make #1332 and #1387 ordering-only, for the `worker`-mode case of gate 5. D5 and - D6 in `single` mode do not wait for them. Root confirms. -- **B15-R8, root decision under owner delegation (2026-10-15):** #1293's dependencies drop #1290 and #1313. #1332 and #1387 - are ordering-only. - -**Other specs that name dropped exports.** #1386 names `miso_engine_web_v1_edit_outcome_ptr`. -#1643 gate uses `miso_engine_web_v1_apply` and cites this slice for it. Root fixes those specs. -Done under B15-R8: #1386 and #1643 each carry an amendment of 2026-10-15. - -**Gates and commands.** Every named script and file exists. `build-web-audioworklet.sh ---named-twin`, `check-sdk-generated.sh [DIR]`, the parameter-metadata `--check` -(`tools/parameter-metadata/src/lib.rs:1143`) and `check-abi-layout-v1.py --self-test` are valid. - -## Amendment (root ruling, batch 15c, 2026-10-16): D6 owns the browser seek report - -- #1316 (batch 15c) shipped the core seek report and its C ABI export, - `miso_engine_v1_source_seek_report` (struct `miso_engine_v1_seek_report`, 64 bytes; the core - reads it through `SessionState::seek_report`, which runs the service step first). The browser - export is this issue's D6, in batch 16. It does not wait for #1332 or #1387, because #1332 waits - on the owner's iOS test and a parity gap there could wait indefinitely. In `single` mode D6 - ships on its own (B15-R8). -- D6 reads the same core call as the C ABI (thick core, thin clients): no seek-report logic in - host-web. Its record carries the same fields as the C struct, in the same order and with the - same meanings (generation in effect, source-read sample and source frame of its first block, - cumulative underrun and held frames). A busy read is `RESULT_BACKPRESSURE` and an unknown source - `RESULT_INVALID_ARGUMENT`, as the C ABI's `source.report.busy` and `source.id.unknown`. -- **New gate (parity row).** In `hosts/host-web/tests/control_parity.rs`: a plain seek and a late - `seek_at` on both hosts at 48 and 96 kHz; after each block, the browser record and the C ABI - struct are equal field by field, from the seek call to the block that applies it and after. - Red if either host reports at the call, names a different block, or drops the lateness. - -## Amendment (controls coordinator, batch 16 check, 2026-10-16) - -Checked against `35ac17545`. - -**Landed dependencies.** #1309, #1316, #1319, #1323, #1562 and #1688 are closed and on main. -#1332 and #1387 stay open and ordering-only (B15-R8). The scope is D5, D6 and D7 (three exports). - -**Moved anchors.** -- `hosts/host-web/src/ffi.rs`: `miso_engine_web_v1_source_seek` `:3251-3276`; `service` - `:3336`; `declare_discontinuity` `:3354`; `control_request_ptr` `:3365`; `control_command` - `:3384`; `control_response_ptr` `:3417`; `control_response_bytes` `:3430`; - `plan_latency_read` `:3474`; `plan_latency_ptr` `:3482`. -- `hosts/host-web/src/lib.rs`: `seek_source` `:3533-3565`. It still checks - `plan.can_prepare_source_seek` (`:3548`), calls `ready.control.seek_unserviced` (`:3551-3553`), - then `plan.prepare_source_seek` (`:3556`). `source_result` `:5181`, `source_failure_result` - `:5197`. `RESULT_REFUSED_LIFECYCLE` equals `RESULT_WRONG_STATE` (`:146`, `:117`). -- `PcmSourceConsumer::prepare_seek` is `crates/source/src/lib.rs:1241` (doc `:1233-1240`). The D4 - trap still holds: it returns `false` while an anchored seek is held. -- `SessionState` (`crates/control-plane/src/control.rs`): `seek_at` `:4073` and `seek_report` - `:4090` both run the service step first, so both are message-handler calls, never `process()`. - `SourceControlSet::seek_report` is `crates/host-core/src/source.rs:258`. -- Mirrors: `expected_exports` `scripts/check-web-audioworklet.sh:274` (its call of - `check-abi-layout-v1.py` `:638`); `EXPORTS` `tools/parameter-metadata/src/abi_layout.rs:126` - (105 names; 108 after this slice) and `scripts/check-abi-layout-v1.py:120`; the self-test - fixture `scripts/fixtures/abi-layout-v1-self-test.json`; `sdk/assets/miso-engine-v1-abi-layout.json`; - `sdk/src/generated/abi.ts`. `scripts/check-sdk-generated.sh` holds no list; it needs no edit. -- The hermetic mock in `scripts/test-web-audioworklet.mjs` (`:2741`) lists only the exports the - worklet script calls (68). No worklet script calls the new exports, so it needs no edit. -- #1688 is the model for D6: `WebPlanLatency` (`lib.rs:1229-1246`), its read and pointer exports, - `plan_latency_fields` (`abi_layout.rs:564`) and its parity rows (commit `6c1f8c2c1`). - -**Stale text and the code's truth.** -- D5 "with the plain seek's argument checks": the plain seek's `can_prepare_source_seek` check - and its own source lookup (`lib.rs:3540-3550`) belong to the `prepare_source_seek` path. D5 - takes the staged-ID checks only and lets the core report an unknown source - (`source.id.unknown`, `RESULT_INVALID_ARGUMENT`). -- D6's record is 48 bytes (`struct_size`, `reserved0`, five `u64`). The C struct - `miso_engine_v1_seek_report` is 64 bytes, with `reserved[2]` after the five words - (`crates/capi/include/miso_engine_v1.h:601-610`; `crates/capi/src/abi.rs:585`). The field names - there are `cumulative_underrun_frames` and `cumulative_held_frames`. #1688's browser record - copied the C size and reserved tail. Root decides whether D6 does too (see the coordinator's - report). -- Gate 3 "renders silence until the anchor": while a seek is held, queued PCM of the playing - generation plays first (header "Held", `:312-316`). The gate holds only with no PCM of the old - generation queued. Set the gate up that way. -- D6's error mapping is already the core's: `ReportBusy` is backpressure and `UnknownSource` is - invalid in `source_result`. No new mapping is needed. - -**Paths.** The parity row (root ruling, batch 15c) is in `hosts/host-web/tests/control_parity.rs`, -which is not in the authorized paths. That ruling authorizes it. - -**Hazard.** The browser's plain seek prepares the consumer at the call (`prepare_source_seek`); -the C ABI's applies at the next block's start. The parity row's plain-seek case compares the -reports. If the browser publishes a different `first_sample` there, the gate is red and the fix is -in the plain seek, outside this slice. Stop and report it; do not change the plain seek here. - -**Decision 17.** D5 and D6 call the same core functions as the C ABI (`SessionState::seek_at`, -`SessionState::seek_report`); host-web keeps staging, pointer checks and the record copy. The -widened C13 list names the C seek report only; B15-R8 and the batch 15c ruling approved D5 and D6. -Root may add the three browser names to the list. - -## Decision record: #1293 (batch 16) - -Root ruling R7 (2026-10-16): the record is 64 bytes, the C struct's layout, reserved words zero. - -**Shipped (D5, D6, D7; gates 3 to 6 and the batch 15c parity row).** -- `miso_engine_web_v1_source_seek_at(handle, source_id_bytes, generation, source_frame, - anchor_sample)`: the staged-ID checks (count inside the staging) only, then - `AudioWorkletEngineHost::seek_source_at`, which calls `SessionState::seek_at` and maps a refusal - through `source_failure_result`. It never calls `plan.prepare_source_seek` (the D4 trap) and is not - `render_locked`: it runs the service step, so the page calls it from the message handler only. -- `miso_engine_web_v1_source_seek_report(handle, source_id_bytes)` and - `miso_engine_web_v1_source_seek_report_ptr(handle)`: `read_seek_report` calls - `SessionState::seek_report` (the C ABI's call) and copies its five words into `WebSeekReport`, 64 - bytes: `struct_size u32`, `reserved0 u32`, `generation`, `first_sample`, `source_frame`, - `cumulative_underrun_frames`, `cumulative_held_frames` (`u64` each), `reserved [u64; 2]`. Busy is - `RESULT_BACKPRESSURE`, an unknown source `RESULT_INVALID_ARGUMENT`. A refusal leaves the record - alone. host-web holds no seek or report logic. -- A host that is not ready (no control half today) refuses both with `RESULT_REFUSED_LIFECYCLE` and - changes nothing; the pointer export returns 0. -- Mirrors: exports 105 -> 108 in `check-web-audioworklet.sh`, `abi_layout.rs` `EXPORTS`, - `check-abi-layout-v1.py` and its self-test fixture; the `seekReport` structure (64 bytes, eight - fields) in `abi_layout.rs`, the checker's `STRUCTURES`/`SEEK_REPORT_FIELDS` and the fixture; - `sdk/assets/miso-engine-v1-abi-layout.json` and `sdk/src/generated/abi.ts` regenerated with - `cargo run -p parameter-metadata -- --print-abi-layout`, `node codegen/assets.mjs` and - `node codegen/generate.mjs`. - -**Departures from the text.** -- `scripts/check-session-map-shape.py` (not in the authorized paths) treated every - `source_*(handle) -> u32` export as a source-map query, so it flagged `source_seek_report_ptr`. - Mechanical fix: it skips record-pointer exports (a name ending in `_ptr`; the fold made the - one-name skip this rule). No other behavior changed. -- The plain seek is unchanged. The amendment's hazard did not materialize: the parity row shows the - browser's report after a plain seek equals the C ABI's at the call (the old generation) and at - every later block (`first_sample` = the first block that begins after the call), at 48 and 96 kHz. - `prepare_source_seek` at the call changes what the consumer reads, not the published report. -- Gate 5 for the pointer export: `pointer_u32` truncates a 64-bit native address to 0, so the - export's value cannot be read natively. The fold split out `seek_report_pointer`, which the - export casts, and the test reads its address before the cast (`test_seek_report_addresses`, the - `test_buffer_address` pattern): the record's address while ready, null once not ready. -- Gate 3's "silence until the anchor" is set up with no PCM of the old generation queued. - -**Gates -> tests.** -- Gate 3 and 4: `tests::the_anchored_seek_exports_play_at_the_anchor_and_report_where_it_landed` - (raw exports: an unaligned anchor and an unknown source are `RESULT_INVALID_ARGUMENT`; an anchor two - quanta ahead is `RESULT_OK`; two silent blocks then the sought frame; the record reads - `(2, 256, 10000)`, 256 held frames, 0 underrun frames, reserved zero). -- Gate 5: `tests::the_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing`. -- Parity row (`hosts/host-web/tests/control_parity.rs`): - `the_seek_report_is_alike_on_both_hosts_for_plain_late_and_future_seeks` (plain seek, late - `seek_at` whose anchor is behind the render clock, and `seek_at` two quanta ahead; 48 and 96 kHz; - record equals the C struct field by field at the call and after every block; output bits equal). -- Gate 6: `python3 -B scripts/check-abi-layout-v1.py --self-test` (36 mutations caught, including - dropping each of the three exports and a record field), `parameter-metadata --check sdk/assets`, - `check-web-audioworklet.sh` (compares the wasm's exports with `expected_exports`), - `check-sdk-generated.sh target/ci/h1293/a`. - -**Commands run.** `cargo test --locked -p host-web --features host-web/test-support` (all pass); -`cargo clippy --locked -p host-web -p parameter-metadata --all-targets --all-features -- -D -warnings` (two packages only; the workspace-wide clippy runs at the batch gates); `cargo fmt --all`; `build-web-audioworklet.sh --named-twin target/ci/h1293/n -target/ci/h1293/a`; `check-web-audioworklet.sh`; `check-sdk-generated.sh target/ci/h1293/a`; -`test-web-audioworklet.sh`; `check-workspace-policy.sh`; `cargo run -p realtime-policy`. - -**Artifact.** `miso-engine-v1-audio-worklet.simd128.wasm`: 4,198,551 bytes, sha256 -`20b08c9049f20b898676f17ea1fda74ea18eda4eabe8a94526c1e80eeb07b5be` (named twin 4,818,838 bytes, -`965b71c5265c0fa8cfa10579856e3f0274b1fba634423edf8e6fd69aba8ec5d7`). - -**Successors.** The SDK binding for the three exports and the message-handler-only rule for -`source_seek_at` (the feed worklet's render-side seek must keep the plain seek) are #1294 and #1297. -Correction (batch 16a fold): at `454d65688` neither spec said so. #1297 D3 had `applySharedSeek`, an -audio-thread caller, call `source_seek_at`, and #1294 D1 still named the dropped `edit_ptr`/`apply` -exports; the fold amends both (M4, outside this record). -A `seek_at_unserviced` for a render-side caller stays unbuilt until a caller needs it. - -**Fold (batch 16a review).** - -- **MINOR 8.** `source_seek_report_ptr` has a behavioural test: in - `tests::the_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing` the pointer - (before its `u32` cast) is the record's address while ready and null once the host is failed. - Red if the pointer names anything else while ready, or anything at all once not ready (both run). -- **MINOR 9.** The ABI self-test swaps the `firstSample` and `sourceFrame` names of `seekReport`, - offsets kept, which only the record's name rule catches (red with that rule deleted, run). It - replaces the record-field drop (caught by the generic layout rules) and two of the three seek - export drops; the `source_seek_at` drop stays. 34 mutations caught. -- **MINOR 10.** `check-session-map-shape.py` states the `_ptr` rule in its docstring and code (16 - self-test mutations caught, unchanged). The gate 3/4 test no longer claims to catch another - source's ring (its session has one source). The staged-ID refusal now follows its operation's - bookkeeping: `source_seek_at` records it as `seek_source_at` records its results, and - `source_seek_report` records none, as `read_seek_report` records none (`with_staged_source_id` - returns the refusal to its caller). The gate 5 test checks both; red if the report export - records its refusal (run). diff --git a/.github/ISSUE_SPECS/1310-supersede-an-unadopted-candidate-plan-by-compare-and-swap.md b/.github/ISSUE_SPECS/1310-supersede-an-unadopted-candidate-plan-by-compare-and-swap.md deleted file mode 100644 index cd5c3513e..000000000 --- a/.github/ISSUE_SPECS/1310-supersede-an-unadopted-candidate-plan-by-compare-and-swap.md +++ /dev/null @@ -1,620 +0,0 @@ -# Supersede an unadopted candidate plan by compare-and-swap - -Stream B of decision 15 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`, D15-9, D15-17). -Code anchors verified on `main` at `6fb211594`. - -## Product outcome - -A host submits a structural transaction while an earlier one's candidate plan has not been -adopted by render yet, including on a paused host, and it commits. The control plane withdraws -the unadopted candidate, prepares one successor that carries both transactions, and publishes it. -Repeated structural edits on a paused host all commit; none returns `BACKPRESSURE` because a -candidate is pending. The watermark reports the displaced revisions as `superseded` when the -successor is adopted. - -## Context - -- **The refusal.** `command` refuses every structural transaction while a candidate is pending: - `if !self.pending_providers.is_empty() { return Err(CommandError::Backpressure) }` - (`crates/control-plane/src/control.rs:1095-1096`). A paused host never adopts, so every later - structural edit is refused for as long as it stays paused. -- **Capacities and admission.** *Size the C ABI's plan capacities and resource admission for a - superseding candidate* (#1398) deletes the #1042 lag check (`:1033`), makes the full publication - or retirement (`:1097-1110`) and full report table or pending list (`:1128-1132`) refusals - impossible, and sums every held plan and model in admission. This slice relies on its - capacities: report rows 4, pending providers 2, retirement capacity 3, retired providers 2. -- **The exchange can take a candidate back** after *Let the control thread withdraw an unadopted - candidate plan* (#1343): `PlanPublisher::withdraw() -> Withdrawal::{Withdrawn, Taken, Nothing}`. - `Taken` means render adopted it. *Adopt a successor plan no earlier than a scheduled sample* - (#1311) keeps these three outcomes: render adopts in the same step as it claims, so no - candidate is ever in flight or handed back. -- **Base and producers.** A candidate is prepared against the newest epoch's inventory and the - current committed model (`control.rs:1043-1050`, `SuccessorBase`, - `crates/host-core/src/prepare.rs:641`). The carry program names its predecessor's plan identity - (`prepare.rs:1296-1297`), so a candidate prepared against an unadopted plan cannot carry from the - plan render actually runs. Persisting source producers move to the candidate after the commit - (`adopt_persisting`, `control.rs:1155`; `crates/host-core/src/source.rs:286`). -- **Host-fed state lives in a candidate.** From the commit on, submissions and seeks address the - newest committed session (`crates/capi/include/miso_engine_v1.h`, "Sources across a structural - transaction"). A source the candidate added already holds PCM, a generation and possibly a held - `seek_at` in the candidate's ring (`PcmSourceConsumer::held_seek`, - `crates/source/src/lib.rs:1018`). -- **The predecessor's model is dropped at commit.** `SessionStore::commit_prepared` overwrites the - compiled session (`crates/protocol/src/model.rs:966`). -- **The watermark** (#1314 D1, D5) carries a `superseded` count beside each candidate's revision in - its mailbox cell. Render adds it once, at that candidate's first advance. #1314 always writes 0; - this slice is the producer. - -## Decisions frozen for this slice - -- **D1. Withdraw first, then prepare.** When a transaction needs a rebuild and a candidate A is - the newest epoch, the control plane withdraws A before preparing. Outcomes: - - **Withdrawn:** A is back unrendered and control-owned, and render still runs P0. The new - candidate B is prepared against P0, with A as a donor (D2). - - **Taken:** render adopted A. A is the base, exactly as today's path with A newest. - - **Nothing:** no candidate is pending; today's path. - - A is published `Next`, `NoEarlierThan(S)` or `Primed` (#1311); the outcomes and every step below - are the same for each. A pending `Primed` warm successor (D15-8 (round-5 amendment)) is an - ordinary pending candidate here. B is published with the adoption kind of its own - classification, never with A's. - - This replaces the plan's "CAS, then re-target if render took it": B's preparation must know its - render predecessor (the carry program names it), and A's host-fed rings can move only while - the control thread owns A. -- **D2. Base across a withdrawn candidate.** B is prepared with `SuccessorBase { inventory: - A.carried_base, committed: P0's kept model (D3) }` (*Prepare a successor across a withdrawn - candidate plan*, #1344 D1-D2). So an owner carries from P0 only if A carried it and B leaves it - unchanged; anything A restarted, added or removed is fresh in B. The sources A created that B - keeps are donated (#1344 D3), and persisting producers move from A's set into B's - (`adopt_persisting(&mut A.sources)`; A holds P0's moved producers). -- **D3. Keep the predecessor's model.** The protocol's structural commit returns the compiled - session it displaces. The control plane keeps it in the epoch being succeeded for as long as a - successor of that epoch is pending, and drops it when that epoch retires. In the withdrawn case - P0 already holds its model, so the model B's commit displaces (A's) is dropped. A live commit - drops its displaced model as today. The kept model is a held compiled model, so both admissions - count it (#1398 D4's "every held model" rule): three models while a successor is - pending and a live edit is admitted. -- **D4. Every check runs while A is withdrawn, before the commit.** In order: preparation of B; the - resource admission; the publication reservation; `check_donation(B, A)` (#1344 D3); the - protocol's commit predicate. Nothing moves before the last one passes. On any refusal A is - republished unchanged, with its own retirement credit and its revision, `superseded` and - `outcome` words (#1314 D1), and the refusal is returned. Republishing cannot fail: the control - thread is the only publisher and the mailbox has an `Empty` cell. -- **D5. Then, infallibly, in this order:** - 1. protocol commit; - 2. `apply_donation` with D4's checked plan (it cannot fail, #1344 D3) and the producer moves; - 3. B's `superseded` word (D6) and revision (#1314 D2); - 4. publish B; - 5. return to P0's epoch every producer A still holds that P0 holds vacant: - `P0.sources.adopt_persisting(&mut A.sources)` (`crates/host-core/src/source.rs:286`). A's - commit moved P0's persisting producers into A's set (`crates/control-plane/src/control.rs:1155`); - step 2 moved into B the ones B keeps, so what remains for P0 are the producers of sources P0 - still renders and B removed. Infallible and allocation-free; keyed by source ID. This covers - a superseded `Primed` warm candidate too: its preparation moved P0's persisting producers into - its set like any successor's; - 6. drop A's plan, provider epoch and credit on the control thread; remove A's report row. For a - warm A this also drops the control plane's `PrimedCandidate` record of A; a B published - `Primed` sets its own (*Classify a latency-growth edit and publish its warm successor from - the control plane*, #1403 D4, which adds the record and this drop). - - B is published only after it owns the donated rings, because render may adopt it at once. Step 5 - runs before step 6 because P0's consumers keep rendering until B is adopted: a producer dropped - with A would leave a source P0 still plays with no producer, and *Remove a strip in two phases: - ramp out, then a scheduled swap* (#1325) D4 routes phase-1 submits for a removed source to it. -- **D6. This slice owns `SUPERSEDED`.** Let `r_P0` be the revision last written to P0's cell (the - control plane keeps it per provider epoch) and `r_B` B's own revision. Every revision after `r_P0` - and before `r_B` was committed onto A or onto a candidate A had superseded, and its content is in - B. The control plane stores `r_B - 1 - r_P0` as B's `superseded` word before publishing B. When - render adopts B, the watermark advances with `SUPERSEDED` set, `superseded` grows by that count, - and `exact` takes the rest (#1314 D5). -- **D7. Live edits, submits and seeks** keep addressing the newest epoch, which is B after D5. - A live edit made while A was pending is in the committed model B is prepared from. -- **D8. The acked-batch question: can an ack ever precede a drop? No.** A's revision was acked - and stays committed: its edits are in the model B is compiled from, and its host-fed sources move - into B (D2). Every way the donation could fail is checked before the commit (D4), so the - post-commit apply cannot lose a ring, and a producer of a source P0 still renders returns to P0's -epoch before A drops (D5 step 5). If B is refused, A is republished untouched (D4). B is acked - only after every fallible step. A's revisions complete when B is adopted, reported as - `superseded` (D6). - -## Deliverables - -1. The supersession path in the control plane's `command`, with D3's model retention and D6's - count. -2. The protocol change of D3. -3. Header text (`miso_engine_v1.h`, after "Sources across a structural transaction"): a - transaction submitted while an earlier replacement is unadopted commits and supersedes it; a - donated source keeps playing; the displaced revisions complete as `superseded`. -4. `docs/CONTROL_PROTOCOL_SEMANTICS.md` and `docs/C_ABI_V1_QUALIFICATION.md`: the same, briefly. - -## Authorized paths - -- `crates/control-plane/src/` (the moved `control.rs` and `compile.rs`). -- `crates/protocol/src/model.rs`, `crates/protocol/src/controller.rs` (D3 only). -- `crates/capi/include/miso_engine_v1.h` (prose only), capi tests. -- `docs/CONTROL_PROTOCOL_SEMANTICS.md`, `docs/C_ABI_V1_QUALIFICATION.md`. - -## Non-goals - -- The exchange mailbox (#1343), the host-core base and donation functions (#1344), the watermark - (#1314), and the capacities and admission terms (#1398). -- Scheduled and primed adoption (#1311). The warm successor's own preparation and fallback - (#1354, #1358). -- The browser (it adopts the control plane in #1332). - -## Objective gates - -1. **Supersession equals one combined transaction (new capi test).** Compile, render one block. - Without rendering, submit T1 (add a source `s` and insert an effect on track `t1`), T2 (change - `t2`'s insert quality), T3 (remove `t3`'s insert). Each returns `RESULT_OK`. Feed `s` and every - persisting source; render 8 blocks. The PCM is bit-identical to a twin that submits T1∪T2∪T3 - as one transaction at the same point. The watermark after the adoption block reports T3's - revision with `EXACT | SUPERSEDED`, `superseded_count` 2 and `exact_count` 1 more than before. - *Red if B carries from A's identity while P0 runs, if the base model is the current one instead - of P0's, if any of T1-T3 refuses, or if the superseded count is off.* -2. **A donated source keeps its PCM and its held seek (new capi test).** T1 adds `s`; submit 4 - blocks of PCM for `s` and `seek_at(s, 2, 0, A)` with `A` four blocks ahead; T2 changes another - track's insert; render 8 blocks. `s` is heard from `A` exactly as in a twin where T1 was adopted - before T2. *Red if B allocates a fresh ring for `s`, dropping acked PCM and the held seek.* -3. **A refused successor restores the candidate (new capi test).** T1 pending; T2 is refused by - preparation (a cap one byte below its need). The response is the compile rejection, the - revision is T1's, and after one render the output equals the T1-only twin. Repeat with each - test fault phase of `TestStructuralFaultPhase` armed during T2, including one between - `check_donation` and the commit: owners and credits balance as in - `every_structural_phase_and_ordered_dual_fault_preserves_owners_and_credits` - (`crates/capi/src/runtime/tests.rs:2094`), which this extends to the withdrawn path. -4. **Three-plan ceiling (new capi test).** A `maximum_graph_session_plus_plan_bytes` that fits two - plans and their models but not three: T1 pending, T2 returns `RESULT_COMPILE_REJECTED` with - `graph.resource.limit`, revision unchanged, and after one render the output equals the T1-only - twin (A was republished). After that render (A adopted, a two-plan peak) T2 again returns - `RESULT_OK`. -5. **Concurrent render (extend `crates/capi/tests/plan_swap_race.rs`).** A render thread and a - control thread submitting 64 structural edits back to back: every submit returns `RESULT_OK`, - and after quiescence the output equals the twin of the final model. The run reaches both - `Withdrawn` and `Taken` (a `test-support` counter per outcome; both nonzero over the run). The - harness's tolerance of a transient structural `RESULT_BACKPRESSURE` (`plan_swap_race.rs:391`) - is removed: any structural `RESULT_BACKPRESSURE` now fails it. The source-submit tolerance at - `:294` is a full ring and stays. -6. **A removed source's producer returns to the running epoch (new capi test).** P0 has sources - `s` and `u`. Without rendering, T1 changes `u`'s track's insert (A persists `s`, so `s`'s - producer moves into A's set), then T2 removes `s`'s track. Through the `test-support` owner - counters and a `test-support` accessor on the epoch's source set: after T2 the running epoch's - set holds `s`'s producer (not vacant), B's set has no `s`, and no producer was dropped on the - control thread. After B's adoption `s`'s producer retires with P0's epoch as today. -7. **Superseded tests.** Delete the assertions that a second structural edit is - `RESULT_BACKPRESSURE` while one is pending (find them under `crates/capi/`); gate 1 replaces them. -8. **Realtime.** `cargo build --locked --release -p audit -p capi && target/release/audit capi`: - allocations, deallocations, locks, syscalls and `total_violations` 0. -9. **Workspace.** `cargo test --locked -p capi`; `cargo test --locked -p control-plane --features - test-support`; `cargo test --locked -p protocol --features test-support`; - `bash scripts/check-capi-abi.sh`; `bash scripts/check-protocol-control-policy.sh`; - `cargo run --locked --release -q -p realtime-policy`; `bash scripts/check-workspace-policy.sh`; - `cargo fmt --all -- --check`; `cargo clippy --locked --workspace --all-targets --all-features - -- -D warnings`. - -## Test value - -- Gate 1: a successor that carries from the wrong predecessor or compares against the wrong - committed model, or a wrong `superseded` count. No test supersedes today, because supersession - does not exist. -- Gate 2: a supersession that drops a withdrawn candidate's host-fed ring, which is an acked - submission lost. -- Gate 3: a refusal path that drops or leaks the withdrawn candidate, or a donation step placed - after the commit (the fault between check and commit must leave both plans whole). -- Gate 4: a three-plan peak admitted beyond a cap, or a refusal that loses the withdrawn candidate. -- Gate 5: a race between withdrawal and render's claim; judged by reaching both outcomes. -- Gate 6: a supersession that drops A with the producers B did not take (step 5 missing or - after step 6) leaves the running epoch's entry for `s` vacant, so a source P0 still renders has - no producer. Gates 1-5 do not see it: none removes a source that A persisted. - -## Amendment 1 (root, 2026-10-05) - -Root ruled one revision gate per plan (*Give each plan its own revision gate and take each block's -live snapshot from it*, #1502), which lands before this issue. The mailbox no longer carries -a revision word. The decisions above keep their meaning, read through the following map. - -- **A candidate's revision** is the revision on its plan's gate. The control plane publishes it - through the gate handle in that plan's provider epoch (#1502 D6). - - D4's "its revision ... words (#1314 D1)" on a republished A: A's plan carries its gate, so - republication keeps A's revision by construction. - - The `superseded` and `outcome` words stay payload words, written before publication. -- **D5 step 3** publishes B's revision on B's gate handle before step 4 publishes B, and stores - B's `superseded` word. -- **D6's `r_P0`** is the revision last published on P0's gate. The control plane keeps it per - provider epoch, as D6 says. -- **Live edits while A or B is pending** go to the newest epoch's producers, stamped and published - through that epoch's gate handle (#1312 D7). So cells and gate follow one routing. -- When A is dropped (D5 step 6), its gate handle drops with its provider epoch. Writes to A that - render never adopts are outside `live_values_superseded`'s unit (#1312 D2). The watermark - reports their revisions as `SUPERSEDED` (D6). - -## Dependencies - -- *Give each plan its own revision gate and take each block's live snapshot from it* - (#1502): the plan gate (Amendment 1). -- *Extract the C ABI control plane into a portable crate both hosts call* (#1309). -- *Let the control thread withdraw an unadopted candidate plan* (#1343). -- *Prepare a successor across a withdrawn candidate plan* (#1344). -- *Publish an applied-revision watermark and complete edits asynchronously* (#1314). -- *Size the C ABI's plan capacities and resource admission for a superseding candidate* (#1398). - -## Amendment (controls coordinator, batch 15 check, 2026-10-15) - -Checked against `e2f6cfb24`. - -**Batch order (root, 2026-10-15).** Batch 15a is the controls lane: #1344, #1398, #1310, in -that order. #1324 left batch 15a (root ruling, 2026-10-15); it lands in a later batch after #1288, -#1391, #1363 and #1325, in that order. The rest of batch 15 follows. #1319 and #1323 need this -slice (root rulings B15-R4 and B15-R5 of 2026-10-15). - -**Moved anchors.** Each fact in Context still holds; only the lines moved. -- The one-candidate refusal is `crates/control-plane/src/control.rs:1742-1744`. The #1042 lag - check is `:1579-1586`. The reservation refusals are `:1751-1765` (four arms since #1343 and - #1311). The full report table or pending list is `:1781-1788`. -- The base: `prepare_runtime` with its `SuccessorBase` literal is `control.rs:1662-1683`. - `SuccessorBase` is `crates/host-core/src/prepare.rs:1070-1097`. The carry program names its - predecessor at `prepare.rs:2131-2134` (and `:3067-3073`). -- The persisting producers move at `control.rs:1846-1849` (`adopt_persisting`, still - `crates/host-core/src/source.rs:286`). -- `SessionStore::commit_prepared` is `crates/protocol/src/model.rs:1384`. The controller's - structural commit is `commit_prepared_structural`, `crates/protocol/src/controller.rs:2260`. -- The exchange: `PlanPublisher::withdraw` `crates/engine/src/realtime/plan_exchange.rs:552`, - `republish` `:586`, `PlanReplacementReservation::set_superseded` `:642`, `UnadoptedCandidate` - `:251`. -- Gate 3's fault test is `crates/capi/src/runtime/tests.rs:2364`. `TestStructuralFaultPhase` is - `control.rs:161-175`. -- Gate 5: the structural tolerance in `plan_swap_race.rs` is now `:420` (a bounded wedge window, - `WEDGE_BLOCKS`); the source-submit tolerance is `:323`. -- Gate 7's assertions are in `crates/capi/tests/resource_lifecycle.rs` (`paused_bursts`, - `:3311-3365`, and the test at `:3078`). - -**Code notes for the implementer.** -- D3: since #1613 a value-only transaction can commit in place (`commit_prepared`'s - `TransactionForm::InPlace`, `model.rs:1397-1400`), with no displaced compilation. A rebuild - never does: the structural arm reads `prospective_session().compiled()` (`control.rs:1662-1667`). - So D3's displaced model always exists on the rebuild path. A live commit while A waits swaps - owners in A's committed model, never in P0's kept one. -- D4 and D5: `check_donation` reads A's plan through `UnadoptedCandidate::plan()`. After the - commit A is never republished, so step 2 takes A's plan with `into_plan()`. That returns A's - credit early, which only frees a credit. No `plan_exchange.rs` edit is needed. -- D4: `republish` panics if a newer epoch was committed after the withdrawal. B's reservation - takes no epoch until its commit (step 4), so a refusal before the commit can republish A. -- D6: `r_P0` is the revision last published on P0's gate (Amendment 1). P0's `ProviderEpoch` - (`control.rs:37`) holds that gate's handle, and `PlanRevisionGate::last_published` - (`crates/engine/src/realtime/plan.rs:663`) reads it. No new field is needed. - -**Decisions.** D1, D4-D7 hold against the code and decision 17. Two do not hold as written. -- **ROOT RULING (2026-10-15): B's base keeps P0's monitor overlay with P0's model, and the edit - ramps committed after `r_P0`, merged with B's own (B wins on a shared row).** This is the - proposal below; D2 and D3 read with it. The finding follows. The rest of B's base (D2, D3). - D2 writes `SuccessorBase { inventory, - committed }`. `SuccessorBase` now also has `monitor` (#1559), `edit_ramps` (#1394) and - `observation` (#1584). `monitor.committed` is "the overlay the predecessor's cells hold"; today - the control plane passes its one `self.monitor` (`control.rs:1677-1681`). While A waits, a solo - edit goes to A's cells (Amendment 1), not P0's. So for B the overlay P0's cells hold is not - `self.monitor`. `edit_ramps` is B's own transaction's. A row that only A (or a live edit while - A waited) changed then ramps with the session's `control_smoothing` in B, and gate 1's twin - (one combined transaction) ramps it with A's length. Proposed: D3 also keeps P0's monitor - overlay beside P0's model, and B's `monitor.committed` is that kept overlay. The control plane - keeps the edit ramps of every edit committed after `r_P0` and drops them with the epoch; B's - `edit_ramps` are those merged with B's own, B's ramp winning on a row both set. `observation` - is the host's bindings as now. -- **ROOT RULING (2026-10-15): B arms its taps from the host's bindings (#1584 D6a). A record kind - the bindings cannot express moves to B before publication, with room checked in D4. A kind that - fits neither stops the implementer, who reports to root.** D8 reads with this. Where the - proposal below says "before step 4", the ruling's "before publication" governs. The finding - follows. Records queued on A (D8). Each epoch's effect producers own the - instance's observation queue (`EffectControlProducer`, `crates/effect-compiler/src/prepare.rs:619`). - A record admitted between A's commit and its adoption waits in A's queue: see - `hosts/host-web/src/tests.rs:7020-7032` ("the record is queued on the successor"). D5 step 6 - drops A with that queue, so an acked record can be lost. D8 says nothing about it. Proposed: D8 - adds that B's preparation arms each tap from the host's bindings as they stand after every - admitted record (#1584 D6a), so B holds what a record queued on A asked for, and the record - drops with A. The implementer shows this for each record kind the queue holds. A kind that the - bindings do not express moves to B's matching instance queue before step 4, with room checked - in D4. If neither holds for a kind, stop and report to root. - -**The browser (non-goal).** "The browser (it adopts the control plane in #1332)" is stale. Since -#1381a the browser runs `control_plane::SessionState` in its one instance -(`hosts/host-web/src/lib.rs:6-16`). Supersession reaches it through the same code, which decision -17 C1 asks for. No browser export changes. Browser tests that expect a second structural edit to -be refused while a candidate waits are superseded with gate 7. Add `hosts/host-web/src/tests.rs` -to Authorized paths for that only, and run `cargo test --locked -p host-web --features -host-web/test-support` in gate 9. - -**Dependencies on GitHub.** -- Closed: #1502, #1309, #1343, #1314, #1311, #1312. -- Open, batch 15a before this slice: #1344, #1398. -- #1403 (open, not in batch 15) adds `PrimedCandidate`; D5 step 6's drop of it is #1403's. #1325 - (open, not in batch 15) is cited only for D5's reason. -- #1482 (open) also edits `plan_exchange.rs`'s area; this slice does not edit that file. - -**Gates and commands.** Every named command, package, feature and script exists -(`scripts/check-protocol-control-policy.sh`, `scripts/check-capi-abi.sh`, protocol and -control-plane `test-support`). - -**Realtime-path edits.** None planned under `crates/engine/src/realtime`. The exchange's -`withdraw`, `republish` and `set_superseded` exist. - -## Decision record: #1310 (batch 15a) - -**Shipped.** -- D1-D6 in `crates/control-plane/src/control.rs`: the structural arm withdraws the newest pending - candidate (`withdraw_pending_candidate`), then `rebuild` prepares, admits, reserves, checks the - donation and the observation forwards, checks the protocol predicate and commits; a refusal - leaves the candidate in `withdrawn` and `command` republishes it. After the commit, in D5's - order: `apply_donation` and `adopt_persisting` from A, B's `superseded` word - (`r_B - 1 - last_published(P0's gate)`) and revision, publication, `adopt_persisting` of A's - remaining producers back into P0's epoch, then A's plan and epoch drop. The one-candidate - `Backpressure` check is deleted. `Taken`/`Nothing` take the old path with A as base. -- D2/D3 (root ruling B15-Q3): `ProviderEpoch::carried_base` (from `PreparedHost::carried_base`, charged - as a capi epoch row with `carried_base_retained_bytes`) and `ProviderEpoch::kept: KeptBase - { model, monitor, edit_ramps }`, set on the epoch a rebuild succeeds, extended by every later - live or superseding commit (`merged_ramps`, the later edit winning), dropped when that epoch is - promoted out. B's base across a withdrawn A is `{ A.carried_base, kept.model, merged ramps, - MonitorBase { kept.monitor, next, ramp } }`. `held_models()` charges every kept model in both - admissions (`compiled_model_admission_over`); the two-model helpers are deleted. -- D3 protocol: `SessionStore::commit_prepared_displacing` and - `CommittedCommandFrame::take_displaced` (`crates/protocol`). -- B15-Q4 (root ruling): an `ObservationSlot::queued` flag marks a tap whose record waits on an - unadopted plan; `observation_forwards` re-queues the tap's bound state on B's matching instance - before publication, room checked before the commit. The observation queue holds only `Observe` - records, which the bindings express; but a tap B carries from P0 takes P0's state at the swap - and never sees a binding, so the record moves (route 2). -- Header, `CONTROL_PROTOCOL_SEMANTICS.md`, `C_ABI_V1_QUALIFICATION.md`: deliverables 3 and 4. - -**Departures and findings.** -- `crates/engine/src/realtime/plan_exchange.rs` (one attempt, accepted): the reservation owns B's - plan, so D4's check and D5's donation need `PlanReplacementReservation::{plan, plan_mut}` - (control side, outside every realtime-policy region). The amendment's "no edit needed" missed it. -- Found by gate 5: with A taken but not promoted, B committed on it leaves two adopted candidates - pending; when the atomic names B, `promote_pending_provider` now retires every older pending - epoch with the running one. Before the fix a race reached `capi.source.epoch` and - `source.ring.vacated`. -- Gate 1: "insert quality" does not prepare (#1344); T2 replaces the insert's effect. Gate 1 adds a - fourth track whose fader T1 moves with its own ramp length, so a base of the current model or a - lost ramp merge is red. -- Gate 3: the cap is `maximum_effect_state_bytes` one byte below B's own; `ProtocolCommit` is in - the fault list (returns `INTERNAL`). -- Gate 5: a new back-to-back race (`structural_edits_back_to_back_supersede_or_swap_and_never_ - backpressure`) with an unlocked control thread; the old races keep their pacing and now fail on - any structural `BACKPRESSURE`. Typical run: 32 withdrawn, 31 taken of 64. -- Gate 7: `paused_bursts` now expects the second paused edit to commit; the swap-window, epoch, - lifecycle, double-live oracle and pressure tests were updated (`resource_lifecycle.rs`, - `runtime/tests.rs`). The live peak with a pending candidate gains the kept model. -- Browser gap (B15-Q4), closed in the follow-up below. - -**Gates** (all pass): `supersede_tests::{a_superseding_replacement_renders_as_one_combined_ -transaction, a_donated_source_keeps_its_pcm_and_its_held_seek, a_refused_replacement_restores_the_ -withdrawn_candidate, a_replacement_over_the_three_plan_ceiling_is_refused_and_the_candidate_kept, -a_removed_sources_producer_returns_to_the_running_epoch}` (gates 1-4, 6; red checked for: step 5 -removed, donation skipped, superseded word 0, base model current, base inventory A's, ramps not -merged); `plan_swap_race` (gate 5, four tests); `resource_lifecycle` (gate 7); -`model::tests::a_compiled_commit_returns_the_compilation_it_displaced` (D3); -`host-web structural_apply::a_structural_edit_behind_an_unadopted_candidate_supersedes_it`. - -**Follow-up (coordinator, same batch).** -1. *Browser copy of forwarded records (B15-Q4).* The core keeps the records the last rebuild queued - again (`SessionState::observation_forwarded() -> &[ObservationForward]`, set by the - superseding commit, empty otherwise). The browser's `run_command` copies each onto the new - plan's `PlanShape` through `note_observe`, the same note `record_observe` makes for an - admitted record (armed bit, arm sample at the command's `next_absolute_sample`, commanded - bit), so the hand-over keeps it; the browser decides nothing itself. Test - `a_subscription_queued_on_a_superseded_candidate_publishes_on_its_replacement` (host-web): - red without the browser copy (mask 0) and red without the core's re-queue (no window). No - parity row: the C ABI admits no observation record, so it cannot reach the case. -2. *B wins on every row it writes (B15-Q3).* `session::EditRamps` now also records rows written with - no length and targets rewritten whole (`clear_target`); `EditRamps::overlay` lays a later - transaction over an earlier one with the later winning on every row it wrote; `get` and - `entries` are unchanged. `merged_ramps` uses it, and the live path's merge runs for any edit - that writes a live row (`is_untouched`). Tests: `edit_ramp::tests::an_overlay_lets_the_later_ - transaction_win_on_every_row_it_wrote` and capi `a_superseding_edit_without_a_ramp_length_ - takes_the_default_on_its_row` (red with the old merge). One transaction may not give a row two - lengths (`control.ramp.conflict`), so that test's reference is the transaction holding T1's - structural edit and T2. -3. *Kept monitor overlay.* `KeptBase::monitor_ramp` keeps the ramp of the latest overlay change - committed since the epoch's last revision; a replacement that writes no monitor row renders - the change between the kept overlay and its own with it. Test `a_solo_committed_while_a_ - candidate_waits_holds_on_its_replacement` (capi): equal to one transaction of T1, the solo and - T2; red with the current overlay as the base and red without the kept ramp. - -**Review fold (batch 15a).** -- *B1 (host difference).* A paused browser page refused the fifth structural edit with - `BACKPRESSURE` (probe: eight add-track transactions without render gave `[OK,OK,OK,OK,6]`) - where the C ABI commits: a superseded candidate's `PendingShape` stayed until a later adoption. - The core now says which plans it holds (`SessionState::holds_plan_epoch`), and the browser drops - the tables and spectrum captures of every unadopted entry whose epoch the core no longer holds, - after each publishing command and in `reclaim_shapes` (`drop_superseded_shapes`); it decides - nothing itself. The `PENDING_SHAPES` comment is corrected. Tests: host-web - `structural_apply::paused_structural_edits_all_commit_and_the_newest_renders_as_a_fresh_boot` - (eight paused edits all OK, the adopted plan holds all eight tracks, superseded count +7, equal - to a fresh boot of the committed model from the adoption block) and parity row - `control_parity::paused_structural_edits_commit_alike_on_both_hosts` (48 and 96 kHz); both red - with the drop disabled (`[0,0,0,0,6,6,6,6]`; parity: `control.plan.backpressure`). -- *M1 (with #1398 D3).* Render claims A and is preempted before P0's retirement reaches control; - C finds A `Taken` and publishes B on it; render adopts B; D finds B `Taken` with `[A, B]` - pending and hit the D3 accounting fault. `withdraw_pending_candidate` now promotes every pending - epoch up to the newest on `Taken`, or on an empty mailbox while one is pending (render adopts in - publication order), so a rebuild meets at most the one candidate it may supersede; the dead - older-epoch loop in the non-superseding branch is a `debug_assert`. New control-plane seam - `test_hold_reclamation` (test-support) keeps displaced plans in the retirement queue. Test capi - `supersede_tests::edits_after_render_took_two_candidates_unretired_each_commit`: two `Taken`, - blocks bit-equal to a paced run, every owner balanced; red without the promotion (the D3 - `debug_assert`). -- *Item 2 (coordinator default, root approved).* A three-plan refusal is `BACKPRESSURE` when the - successor fits beside the newest held plan alone and the committed model (what a retry meets - after render adopts the candidate), and `COMPILE_REJECTED` only when it cannot fit even then. - The second check charges the candidate's rows as prepared, so a ring it takes by donation is - counted twice there: it errs toward the permanent refusal by at most those rings' bytes, never - toward an admission. Tests: gate 4 now expects `Backpressure` at a cap between the two- and - three-plan peaks and adds a cap between P0+A and A+B that gives `graph.resource.limit` before - and after A is adopted; `runtime::tests::a_valid_edit_inside_the_swap_window_is_admitted_ - against_the_running_plan` expects `Backpressure` for the pending case (each red against the - other behavior). Header, `C_ABI_V1_QUALIFICATION.md` and `CONTROL_PROTOCOL_SEMANTICS.md` say so. -- *m3.* Test capi `a_live_ramp_committed_while_a_candidate_waits_holds_on_its_replacement`: T1 - pending, a live `t4` fader edit with a one-sample ramp on A's lanes, T2 supersedes; equal to one - transaction of all three; red with the live path's kept-ramps merge deleted. -- *m4.* Gate 3 adds a case whose withdrawn candidate is B (superseded word 1): a fault before the - protocol commit republishes B, whose adoption completes T1 as superseded and T2 as exact; a - retried T3 completes two as superseded. Red with `PlanPublisher::republish` writing a zero word - (checked in a scratch copy outside the worktree; no edit under `crates/engine/src/realtime/`). -- *m5.* The `newest_providers` doc moved off `kept_holder`; `held_models` refuses a kept model with - no row (`debug_assert!` and `INTERNAL`) instead of skipping it; the `synchronize_plan_epochs` and - `promote_pending_provider` comments name every pending candidate. -- *Header gap.* The header's "neither refeeds nor reseeks" holds for every source the new plan - keeps from the waiting replacement or from the running plan, except one case: a source or owner - that A restarted or removed and B reverts to its P0 form starts fresh in B. That gap closes with - the successor issue "Carry an owner or source a superseding candidate keeps unchanged from P0 - from P0's state". -- *Edits outside the original authorized paths, and who accepted them.* - `crates/session/src/edit_ramp.rs` (coordinator, under B15-Q3); `hosts/host-web/src/lib.rs` - (coordinator, under B15-Q4); `crates/engine/src/realtime/plan_exchange.rs`, the reservation - accessors (one attempt, accepted by root on 2026-10-16); `crates/capi/src/runtime/mod.rs` - (mechanical). - -**Review fold 2 (batch 15a).** -- *B-1 (C ABI wedge).* Two `Taken` outcomes while render lagged its retirements parked two epochs - (`retired [0,1] current 2 pending [3]`); render then adopted C and published epoch 3, and every - later call promoted from the atomic before it drained, found `2 + 1 > 2` and answered - `INTERNAL` forever. Fix, both halves: `RETIRED_PROVIDERS = RETIREMENT_CAPACITY` (3; each parked - epoch's plan holds one retirement credit), and `synchronize_plan_epochs` reclaims every - reclaimable plan before it promotes from the atomic. Test capi - `supersede_tests::two_taken_candidates_and_a_published_adoption_never_wedge_the_session` - (hold-reclamation and unpublished-render seams; asserts the reported state, then four blocks of - service and a fourth edit commit, owners balanced). Red: with both halves reverted the test - fails at the held service step, before the hold clears (the step that reclaims nothing answers - `Internal`; fold 3, m-A, corrected the earlier text, which placed it at the first call after the - hold clears); with only the capacity reverted, the test's step that reclaims nothing cannot park - a third epoch (`Internal`). -- *M-1 (a displaced plan uncharged after a `Taken` promotion).* `ProviderEpoch::parked: - Option` captures an epoch's report row, capi resources and carried bytes - when it is parked (the report table drops the row; no later commit changes it), and - `held_epochs` now returns every parked epoch first, then the running plan, then the candidates, - so both admissions charge a plan until it is reclaimed. Ordering keeps M2: an epoch carries only - from the one before it in that list or from a plan already reclaimed, so "every held epoch but - the oldest leaves its carried bytes" counts each ring once. The `held_epochs` and admission docs - say so. Test capi `a_displaced_plan_not_yet_reclaimed_is_charged_after_a_taken_promotion`: - gate 4's cap (269,263, three-plan peak 311,372) gives `BACKPRESSURE` with P0 parked, one service - clears it and the same T2 commits; a source-total cap at the counted-once peak (24,430 = 11,394 - + 22,478 - 10,640 + 22,478 - 21,280) admits and one byte below refuses. Red: parked epochs left - out gives `Ok` at the graph cap and `Ok` at 24,429; parked epochs listed after the running plan - (double count) refuses at 24,430. -- *m-1.* The header (supersession paragraph and the sizing rule), `CONTROL_PROTOCOL_SEMANTICS.md` - and `C_ABI_V1_QUALIFICATION.md` no longer promise that a retry after a render commits: - `BACKPRESSURE` means the edit fit against the newest held plan alone, so a retry normally - commits, within a small band (about +64 graph and +158 capi bytes between B prepared across A - and B' on adopted A; a donated ring counted twice in the second check). The sizing rule notes - that a plan render has just displaced counts until reclaimed. -- *m-5 (browser).* Test host-web `structural_apply::a_superseded_candidates_spectrum_handover_ - drops_with_the_superseding_command` (single capture, stream running): A's entry and hand-over - drop with T2's command, B's waits, the service after adoption promotes it and the stream - publishes windows; red with the command's `drop_superseded_shapes` disabled. Test - `structural_apply_realtime::a_superseded_candidate_with_a_spectrum_hand_over_costs_no_render_ - call_an_allocation`: render-locked allocator calls 0 over every block, no attachment drop in - render; red (7 calls) when the adoption block drops the superseded entry's captures. -- *Found with m-5, fixed (outside the listed items).* `promote_spectrum` evaluated - `entry.spectrum.take()` for every entry before checking `adoption`, so any service step or - command between a rebuild's commit and its adoption block dropped the waiting candidate's - spectrum hand-over. It now takes only an adopted entry's. The m-5 test asserts the hand-over - survives a service step before adoption (red without the fix). A stream test with a single - carried capture stayed green on the bug (the service's carried reader keeps serving), so the - user-visible effect is unproven for that case and the test was not kept; a non-carried capture, - a one-shot or a collection may differ (not investigated). -- *Re-pin.* Reference session `capi_retained_bytes` 165,922 -> 168,810 (x86-64, +2,888: one more - 1,648-byte provider-epoch slot and the 248-byte parked row in each of five slots), within the - 175,296 ceiling, which stays (`resource_lifecycle.rs`, `C_ABI_V1_QUALIFICATION.md`). -- *Acked PCM on a reverted source (root ruling of 2026-10-16, the acked-batch rule).* The header - gap was worse than "starts fresh": when A redeclared a source X, the host fed A's restarted X - ring (acked), and B declares X as P0 does, X is not donated (its declaration differs between A - and B), B prepares it fresh, and A's acked chunks dropped with A. Guard: - `host_core::drops_acked_reverted_source(B's model, A's donation side, P0's kept model)` (true - when such an X's producer on A is no longer unfed: a chunk, a seek or an end of region), called - in `rebuild` after preparation and before the admission; it answers `BACKPRESSURE`, so the - caller republishes A unchanged, no event is queued and the revision stays. After render adopts - A, the retry commits on A. It is small (one read-only function, one call; no state), so it was - coded. Tests: capi `supersede_tests::a_supersession_that_would_drop_acked_pcm_on_a_redeclared_ - source_is_backpressure` (refusal, A still the candidate, no event, revision 44; every block - bit-identical to the sequential run; the adoption block differs from a run whose host never fed - A's ring, so the acked chunk is heard) and parity row `control_parity::a_supersession_that_would_ - drop_acked_pcm_is_backpressure_alike_on_both_hosts` (48 and 96 kHz: both hosts `6`, same - revision, no browser event, retry commits alike, same bits after). Red without the guard: capi - `Ok(80)`; parity `(0, 0)` against `(6, 6)`. The header, `CONTROL_PROTOCOL_SEMANTICS.md` and - `C_ABI_V1_QUALIFICATION.md` now say such a supersession is refused with `BACKPRESSURE` until A - adopts; #1698 lifts the guard (a line added to its spec). -- *Edits outside the original authorized paths.* `hosts/host-web/src/lib.rs` (`promote_spectrum` - only), `hosts/host-web/tests/structural_apply_realtime.rs` (the m-5 cost test), - `hosts/host-web/tests/control_parity.rs` (the guard's parity row), - `.github/ISSUE_SPECS/1698-carry-an-owner-a-superseding-candidate-keeps-unchanged-from-p0.md` (one line). No edit under - `crates/engine/src/realtime/`. -- *Parity row's silent adoption block (harness, not engine).* A plain run of this session (no - edit) first outputs a non-zero sample at frame 459 at 48 kHz and 939 at 96 kHz (the plan's - compensated latency, which grows with the rate), so its first playing block is 3 / 7 and block 2 - was silent on both hosts for that reason alone (probe, not kept). The row now commits T1 at block `lead + 1` (`lead` = a plain run's first - playing block), asserts the adoption block plays, renders to block `adopt + lead` before the - retry, and asserts there that the output plays and differs from a control run whose host never - fed A's `src-t1` ring (blocks before it bit-identical), bits equal on both hosts at both rates. - Red: the control run also fed the acked chunk (`assert_ne`, block 7 at 48 kHz); adoption at - block 2 ("the adoption block plays"). - -**Review fold 3 (batch 15a).** -- *M-A (fold 3; root ruling M-A of 2026-10-16).* The fold-2 guard covered one of the ways A's fed - ring drops with A. Widened: B is refused with `BACKPRESSURE` before its commit when some source - of A has a fed producer (a chunk, a seek or an end of region) on a ring A created and the - donation `check_donation` returns for A -> B does not move it. Cases: (a) A redeclares X, B - declares X as P0 does (the fold-2 case); (b) A redeclares X, B redeclares it a third way; (c) A - redeclares X, B removes it; (d) A adds X, B removes or redeclares it. Not in scope: a source A - carries unchanged from P0 (its chunks are in P0's ring, and B treats it as a direct P0 -> B swap - would). "A created the ring" is `donor.plan.can_prepare_source_seek(index)` on X's index in A's - inventory: a carried source is prepared vacant (its ring arrives at the swap), so this is false - for it, the same freshness test `check_donation` applies. "Not donated": no pair in the - donation's consumer half has X's donor index. The function is renamed - `host_core::drops_acked_fresh_source(A's donation side, &DonationPlan)` (read-only, - allocation-free: a linear scan of the plan's pairs per source of A). It reads the donation plan, - so its call moved in `rebuild` from before the admission to right after `check_donation`, which - runs after the plan reservation; the reservation is cancelled by the return (it returns its - credit and consumes no epoch, as the `AfterPlanReservation` fault path already relies on), so - the refusal still commits nothing, queues no event, and the caller republishes A. A host that - does not render keeps getting `BACKPRESSURE` for such an edit until it renders. m-C is moot: the - guard no longer reads `self.providers.kept`. Tests: capi `supersede_tests::a_supersession_that_ - would_drop_acked_pcm_on_a_fresh_source_is_backpressure` (renamed from `..._on_a_redeclared_ - source_...`; five cases (a), (b), (c), (d) removed, (d) redeclared, each: `BACKPRESSURE`, - revision unchanged, A still the candidate, no event, every block bit-identical to the sequential - run, the adoption block differs from a run that never fed A's ring); host-core - `withdrawn_successor::the_guard_refuses_exactly_a_fed_fresh_ring_the_donation_leaves_behind` - (refused: (a)-(d); not refused: a fed `c` B keeps (donated), A's fresh `c` unfed, a source A - carries from P0 fed through A's producer, a reverted source unfed); parity row - `control_parity::a_supersession_that_would_drop_acked_pcm_is_backpressure_alike_on_both_hosts` - extended to (a), (b) and (c) (B removes `src-t1`, `t1` and its routes) at 48 and 96 kHz. Red - with the fold-2 guard restored (this fold's tests unchanged): capi (a) passes and (b), (c), (d) - removed and (d) redeclared each commit (`Ok(80)`); parity `(0, 0)` against `(6, 6)` at 48 kHz - case (b). Docs: the header supersession paragraph, `CONTROL_PROTOCOL_SEMANTICS.md` and - `C_ABI_V1_QUALIFICATION.md` state the widened rule, the paused-host cost, and that a source the - caller's own edit removes discards its queued audio. #1698 now lifts only case (a). -- *Removal contract (root ruling, fold 3).* A source the caller's own edit removes discards its - queued audio: a direct P0 -> B removal, and B removing a source A carried unchanged from P0. The - caller asked for it, so it is not an unintended drop; the header and - `CONTROL_PROTOCOL_SEMANTICS.md` say so. No counter or telemetry field reports those frames - (source telemetry is per live source and leaves with it; the watermark outcome flags and the - counters name none): #1699 (placeholder, no spec yet) is the successor issue for a counter of - the frames such a removal discards. -- *m-A.* B-1's red text corrected above: with both halves reverted, the wedge test fails at the - held service step. -- *m-B.* The drain-before-promotion order in `synchronize_plan_epochs` (B-1's second half) is - defence in depth, not a tested gate: at capacity 3 each parked plan holds a retirement credit, - so the parked list has room whichever order runs, and no test is red without the order. Code - kept; its comment says so. The B-1 test defends the capacity half only. -- *Task B-A (doc job).* Five private intra-doc links in `crates/host-core/src/source.rs` - (`donate_producers`, `donate_consumers`, `unfed`) are plain code spans; - `RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --no-deps` passes. - -## Amendment (#1698, batch 15b, coordinator ruling of 2026-10-16 confirmed by root) - -D2's base across a withdrawn candidate is now P0's own inventory less the sources A did not carry -from P0 (`PlanStateInventory::across_withdrawn`), so B carries from P0 an owner it keeps unchanged -from P0, as a direct P0 -> B swap does. `ProviderEpoch::carried_base` and its capi epoch term are -gone (reference `capi_retained_bytes` 168,810 -> 167,514). The fold-3 guard -`drops_acked_fresh_source` stays for all four cases, (a) included: a source A added or redeclared -restarted the host's feed against A (`docs/CONTROL_PROTOCOL_SEMANTICS.md:23`), so B starts it -fresh and cannot read A's chunks into P0's continuing source. The guard's capi and parity rows stay. -See #1698's decision record. diff --git a/.github/ISSUE_SPECS/1316-anchor-every-seek-on-the-plans-source-read-clock.md b/.github/ISSUE_SPECS/1316-anchor-every-seek-on-the-plans-source-read-clock.md deleted file mode 100644 index 973250fe5..000000000 --- a/.github/ISSUE_SPECS/1316-anchor-every-seek-on-the-plans-source-read-clock.md +++ /dev/null @@ -1,319 +0,0 @@ -# Anchor every seek on the plan's source-read clock - -Stream B of decision 15 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`, D15-12, D15-17). -Code anchors verified on `main` at `6fb211594`. - -## Product outcome - -Every seek is anchored, and the host can see where it landed. A `seek_at` anchor is a sample of the -plan's **source-read clock** ("equal to the render clock in this version"). A plain seek during -playback means "the next block render begins". For both, render publishes a per-source seek report: -the generation in effect, the source-read sample of the first block it played in, and the source -frame that block started at, plus the source's cumulative underrun and held frames. A host that -scrubs one stem with a plain seek and then starts another with `seek_at` can compute the second -stem's frame exactly, instead of guessing one or two quanta (round-1 finding D1-b). - -## Context - -- `begin_block_at(first_sample)` (`crates/source/src/lib.rs:1108-1117`) applies a held anchored - seek in the block whose first sample reaches the anchor, at `frame + lateness` - (`observe_seek_at_block_boundary`, `:1323-1363`). The graph driver passes the render block's - first sample (`:1719-1721`). D1-a: once #1396 makes the graph read sources ahead of render, an - anchor on the render clock would drift by that read-ahead; anchoring on the source-read clock - keeps a host that never learns the read-ahead aligned. -- A plain seek applies at the first block render begins after the command is popped. Nothing - reports which: the producer flips its `active_generation` at the call - (`crates/source/src/lib.rs:776-779`), and the C ABI's submit report returns that producer value - (`crates/capi/src/ffi.rs:496-499`). There is no applied-seek event. -- The ring's resources are reported row by row in `SourceResourceReport` - (`crates/source/src/lib.rs:208-235`, built in `resource_report` at `:490-547`). -- The C ABI reaches producers through `SourceControlSet` (`crates/host-core/src/source.rs`, `seek` - `:200`, `seek_at` `:223`) of the newest committed session (`newest_providers`, - `crates/control-plane/src/control.rs:1628`). -- The engine's single-writer seqlock with bounded reads is `crates/engine/src/realtime/observe.rs`; - #1314 adds `crates/engine/src/realtime/watermark.rs` on the same pattern. -- The header defines the anchor on "the clock miso_engine_v1_render_f32_planar takes" - (`crates/capi/include/miso_engine_v1.h:96-99`). The header rewrite is #1317's. - -## Decisions frozen for this slice - -- **D1. Clock name.** `begin_block_at`'s parameter is renamed `source_read_sample` and documented as - "the plan's source-read clock: the absolute sample of source time this block reads; equal to the - render clock in this version". `SeekAt::anchor_sample` and `HeldSeek::anchor_sample` are - documented on the same clock. The graph driver passes its `first_sample` unchanged; *Give a plan - a source-read clock that leads its render clock* (#1396) is the issue that makes the two clocks - differ, and it must pass the source-read sample here. -- **D2. Per-ring seek report.** Each ring gets one shared record, allocated with the ring on the - control thread and charged as a new `SourceResourceReport::seek_report_bytes` row (in overhead - and total). Words: `generation`, `first_sample`, `source_frame`, `cumulative_underrun_frames`, - `cumulative_held_frames`. Single writer: the consumer, on the render thread. Reads: the producer - side, bounded retries, "busy" on give-up. Reuse #1314's record module: generalize it to a - fixed-width word record (`PublishedWords`) that both the plan watermark and this - report instantiate. No `unsafe`. -- **D3. When render publishes.** At the end of `begin_block_play`, the consumer publishes when its - `active_generation` differs from the published generation (the ring's first block, and every - block a plain or anchored seek applies at) or when a cumulative counter changed. `first_sample` - is this block's `source_read_sample`; `source_frame` is the frame the block started reading - (`next_frame` before this block's advance; for a late anchored seek, `frame + lateness`). - Otherwise nothing is written. Before the ring's first block the record reads generation 0, - meaning "not yet rendered". -- **D4. Plain seeks are anchored at the next block.** No state-machine change: a plain seek keeps - applying at the first block that observes it. D3 makes that block visible. A seek prepared - between blocks by an exclusive host (`prepare_seek`, `:1049`) is reported at the next block, - because that is the first block that plays it. -- **D5. Carry.** The record belongs to the ring, so it moves with the consumer across a plan swap - (the carry swaps the whole consumer, `crates/source/src/lib.rs:1880`). A source that a transaction adds or changes gets a new ring - and starts at generation 0. -- **D6. C ABI.** `uint32_t miso_engine_v1_source_seek_report(miso_engine_v1_session *session, - const uint8_t *source_id, uint64_t source_id_bytes, miso_engine_v1_source_seek_report *out)`, - thread: session (control), serialized with the other session calls. Struct (64 bytes, - `MISO_ENGINE_V1_SOURCE_SEEK_REPORT_SIZE`): `uint32_t struct_size; uint32_t reserved0; - uint64_t generation; uint64_t first_sample; uint64_t source_frame; - uint64_t cumulative_underrun_frames; uint64_t cumulative_held_frames; uint64_t reserved[2];`. - It reads the newest committed session's ring (like `seek`). Unknown source: - `INVALID_ARGUMENT`, `source.id.unknown`. Wrong size or nonzero reserved: `INVALID_ARGUMENT`. - Busy read: `BACKPRESSURE`, `source.report.busy`. A malformed source ID (null, empty, non-UTF-8, - over 127 bytes) returns `INVALID_ARGUMENT` here as on `seek`; its `source.id.invalid` diagnostic - is #1350's, which lands after this slice and covers this entry point. Feature bit - `MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_REPORT`: the next free bit when this merges, never a reused - one; tests and the smoke program use the symbol, never a number. The frozen symbol list grows - by one. -- **D7. Facade.** `SourceControlSet::seek_report(id) -> Result` - in host-core, reading the producer's reader; a vacated or vacant entry is `source.ring.vacated` - as for `seek`. Its `seek_at` doc (`crates/host-core/src/source.rs:209-221`) says the source clock - and "holds" instead of "reports an underrun" (#1318). -- **D8. Acked-batch question.** No queue is added; the report is a level written by render. Seek - admission and its BACKPRESSURE are unchanged. - -## Deliverables - -1. D1-D5 in `crates/source/src/lib.rs`; D2's generalization in `crates/engine/src/realtime/`. -2. D7 in host-core; D6 entry point, struct, constants, bit; `abi_smoke.c`, `header_smoke.cpp`, the - frozen list; a two-line header note of the new call (the full seek text is #1317's). - -## Authorized paths - -- `crates/source/src/lib.rs`, `crates/source/tests/*.rs` -- `crates/engine/src/realtime/watermark.rs`, `mod.rs` -- `crates/host-core/src/source.rs` (`seek_report` and the `seek_at` doc only) -- `crates/capi/src/abi.rs`, `ffi.rs`, `lib.rs`, `runtime/error.rs`, `runtime/tests.rs`, - `include/miso_engine_v1.h`, `tests/c/abi_smoke.c`, `tests/c/header_smoke.cpp` -- `crates/control-plane/src/control.rs` (the successor #1309 made of - `crates/capi/src/runtime/control.rs`) -- `scripts/check-capi-abi.sh` (frozen list only), `docs/C_ABI_V1_QUALIFICATION.md` - -## Non-goals - -- The source-read offset itself (#1396). The browser export of the report (stream H, #1332 and the - rewritten #1293). The seek header text (#1317). -- The `source.id.invalid` diagnostic for a malformed ID on this entry point: *Tighten the seek - entry points: source.id.invalid, a typed held preparation, timed reads only* (#1350), which - depends on this slice and gates it. The shared service step that this session call runs first: - *Add miso_engine_v1_service for bounded control work between edits* (#1348). - -## Objective gates - -1. **Plain seek reported.** C ABI test: render blocks 0..5 of a playing source, plain-seek it to - generation 2 frame 4096 (any in-region frame), submit generation 2 from it, render block 6: - the report is `(2, 768, 4096, 0, 0)`. -2. **Late anchored seek reported.** `seek_at(id, 2, F = 384, A = 896)` after block 9 has rendered - (A already past), render block 10: the report is `(2, 1280, 768, ..)`, matching the - `F + (1280 - 896)` start that the PCM bit-identity check of the existing #1275 tests asserts. -3. **Scrub, then align.** One test: plain-seek stem X during playback, read its report - `(g, s, f)`; add stem Y by transaction and `seek_at(Y, 2, f + (A - s), A)`; the output from A on - is bit-identical to a fresh session fed both stems from the aligned frames. -4. **Carried and restarted rings.** The report of a persisting source survives a plan swap - unchanged; an added source reads generation 0 until its first block, then `(1, swap block, 0)`. -5. **Counters.** A held block raises `cumulative_held_frames`, a true underrun - `cumulative_underrun_frames` (needs #1318). -6. **Render stays clean.** `./target/release/audit capi` keeps `"total_violations":0`; the - `crates/source` resource test that pins row sums adds the new row. -7. Commands: - - `cargo test --locked -p source` and `cargo test --locked -p engine --features engine/realtime-audit` - - `cargo test --locked -p host-core --features host-core/test-support` - - `cargo test --locked -p capi` - - `bash scripts/check-capi-abi.sh` and `bash scripts/check-capi-abi.sh --self-test` - - `cargo build --locked --release -p audit -p capi && ./target/release/audit capi` - - `cargo run --locked --release -q -p realtime-policy`, `bash scripts/check-cross-targets.sh` - - `bash scripts/check-workspace-policy.sh`, `cargo clippy --locked --workspace --all-targets -- -D warnings`, `cargo fmt --all -- --check` - -## Test value - -- Gate 1: red if render publishes the producer's generation at the call, or the block after the - one that applied the seek; nothing today reports where a plain seek landed. -- Gate 2: red if the report omits the lateness (reports F, not `F + 384`). -- Gate 3: red if the report's sample and frame are not on the same clock the anchor uses; it is - the host workflow D1-b names. -- Gate 4: red if the record lives on the plan instead of the ring (lost on swap) or an added ring - inherits a predecessor's record. -- Gate 5: red if the report reads the per-block flags instead of the cumulative counters. - -## Dependencies - -- *Publish an applied-revision watermark and complete edits asynchronously* (#1314): the record - module this generalizes. -- *Report held source blocks apart from underruns* (#1318): the held counter this reports. - -## Amendment (controls coordinator, batch 15 check, 2026-10-15) - -Checked against `228fca26c`. - -**Moved anchors.** Each fact in Context still holds; only the lines moved. - -- `begin_block_at` is `crates/source/src/lib.rs:1115` (doc `:1106-1114`). It and - `begin_block_play` (`:1135`) now take a private `SeekClock` (`:1030-1038`, the enum at `:1031`). D1's rename is - `SeekClock::At`'s field and `begin_block_at`'s parameter. -- `observe_seek_at_block_boundary` is `:1323-1365`. The acquire step can re-observe a command - inside `begin_block_play` (`acquire_current_block`, `:1394-1440`, #1274 MINOR-3). D3's - publication at the end of `begin_block_play` sees both. -- The graph driver's block entry is `:1752-1785`; the call is `:1772`. -- The producer flips `active_generation` in `try_seek` at `:778` (`try_seek` `:753-787`). The - C ABI submit report copies it at `crates/capi/src/ffi.rs:501`. -- `SourceResourceReport` is `:209-236`; `resource_report` is `:490-546`. The row-sum test of - gate 6 is `report_separates_session_pcm_from_source_overhead` (`:2056`). -- `prepare_seek` is still `:1049`. The carry's `mem::swap` is `:1931` (`adopt_sources`, - `:1902-1938`). -- `SourceControlSet::seek` is `crates/host-core/src/source.rs:200`, `seek_at` `:223`, its doc - `:209-222`. -- `newest_providers` is `crates/control-plane/src/control.rs:2557`, `newest_providers_mut` - `:2578`. `SessionState::seek` is `:3003`, `seek_at` `:3028`. -- The header's anchor text is `crates/capi/include/miso_engine_v1.h:204-216` (the clock clause - `:205-207`). The session-thread table is `:18-22`. - -**Dependencies on GitHub.** - -- #1314 is closed. `crates/engine/src/realtime/watermark.rs` is on this tree. -- #1309 is closed. `crates/control-plane/src/control.rs` is correct in Authorized paths. -- #1318 is open. It is in the batch-15/16 list. -- #1348 is closed. Its service step exists (`SessionState::service`, `control.rs:1486`). -- Downstream, not dependencies: #1350, #1293, #1332, #1387, #1537, #1640 and #1641 (open). #1641 - plans a seek-report row for the timeline, which has no ring. D2's record shape must not stop a - later non-ring owner from publishing the same words. - -**Decisions.** - -- D1, D3, D4, D5, D7 and D8 hold against the code. -- D6 and #1348. The header (`:148-153`) says every session call that passes its argument checks - runs the service step first. Now that #1348 has landed, the new call follows that rule: - `SessionState::seek_report` calls `self.service()` first, as `seek` does, and the header's - thread table and service paragraph list the new call. This applies #1348 D2. It does not change - D6's results. -- D6 needs a re-export in `crates/capi/src/runtime/mod.rs` and `crates/control-plane/src/lib.rs`. - Add both to Authorized paths for re-exports only. -- D6's feature bit: the mask is 255 today (`MISO_ENGINE_V1_FEATURE_SERVICE` is 128). The rule - "next free bit when it merges, by symbol" stands. -- **B15-R1, root decision under owner delegation (2026-10-15): one shared seqlock module, `crates/engine/src/realtime/seqlock.rs`. - #1316 lands first and creates it. #1482 D3 later moves `observe.rs` onto it.** (D2 against - #1482 D3.) D2 puts the generic part in `seqlock.rs`, not in `watermark.rs`, and `watermark.rs` - uses it. Add `crates/engine/src/realtime/seqlock.rs` (new) to Authorized paths. The finding - follows. D2 makes #1314's record a generic `PublishedWords` in `watermark.rs`. *Attribute each claim's revisions exactly in - the watermark and share one seqlock* (#1482, open, not in the batch-15/16 list) D3 puts the - seqlock protocol in a new `crates/engine/src/realtime/seqlock.rs` that `observe.rs` and - `watermark.rs` both use. Done in either order without a rule, the two slices build two shared - abstractions or a third copy of the seqlock. Proposed resolution: one shared seqlock module. - If #1482 lands first, D2 builds the seek report on `seqlock.rs` and leaves `watermark.rs` - untouched. If this slice lands first, D2 puts the generic part in `seqlock.rs` (added to - Authorized paths) and #1482 D3 then moves `observe.rs` onto it. Root picks the order. -- **B15-R2, root decision under owner delegation (2026-10-15): keep D6 as a direct C export, `miso_engine_v1_source_seek_report`.** - (D6 against decision 17 C13.) The logic is one core function, - `SourceControlSet::seek_report`, behind both hosts. Source submission, seeks and the seek report - are transport under decision 17 C1, so their direct exports are not C13 exceptions. Decision 17's - C13 line records this (`docs/rulings/one-control-abstraction-2026-10-13.md`). The finding - follows. C13 says a new feature gets "its C ABI - form (a protocol message through `miso_engine_v1_submit_command`, no new C export)". D6 adds - a new C export, `miso_engine_v1_source_seek_report`. Root's decision-17 amendments to #1293 kept - that slice's browser seek-report export (#1293 D6), so root seems to treat source seeks and - their reports as host transport (C1: "frame staging"), like `seek` and `seek_at`. Proposed - resolution: keep D6 as a direct export. Record that source submission, seeks and the seek - report are transport under C1, with one core function (`SourceControlSet::seek_report`) behind - both hosts. The alternative is a protocol message, which would need a new message ID and a - source-ID field in the protocol. - -**Gates and commands.** Every named script, package and feature exists. `crates/source/tests/` -holds `randomized.rs` and `seek_schedule_model.rs`. Gate 6's "`crates/source` resource test" is -the unit test named above. Moved pins from the new per-ring row can reach -`crates/capi/tests/resource_lifecycle.rs` and `hosts/host-web/tests/browser-v1/expected.json`. -Re-pin from the measurement, with the reason. - -**Realtime-path edits.** D2 edits `crates/engine/src/realtime/watermark.rs` and `mod.rs` and adds -the new `seqlock.rs` (B15-R1). D3 adds a render-side publication in the source consumer. That slice gets -one attempt. - -## Decision record: #1316 (batch 15c) - -**Shipped.** D1-D8 and B15-R1/R2 on `460fca68b`. - -- `crates/engine/src/realtime/seqlock.rs` (new, B15-R1): `PublishedWords`, a - non-`Clone` `PublishedWordsWriter::publish(&mut self, &[u64; N])` (marked realtime region, - wait-free) and a `Clone` `PublishedWordsReader::read` bounded at `MAXIMUM_READ_ATTEMPTS` (64), - then `PublishedWordsBusy`. No `unsafe`. `watermark.rs` now stores its six words through it; its - record size is unchanged (72 bytes). #1314's torn-read test moved to `seqlock.rs` with the - protocol and the watermark copy was deleted. -- `crates/source`: `begin_block_at(source_read_sample)` and `SeekClock::At { source_read_sample }` - with D1's text; `SourceCommand::SeekAt` and `HeldSeek` anchors documented on the source-read - clock. `SourceSeekReport` (five words) and `SourceSeekReportBusy`; one record per ring made in - `prepare_at_source_frame`, writer in the consumer, reader in the producer - (`PcmSourceProducer::seek_report`, `HostChunkProvider::seek_report`). D3's publication is - `publish_seek_report` at the end of `begin_block_play`, inside the existing marked region. - `SourceResourceReport::seek_report_bytes` is in overhead, total and the largest-allocation list. -- host-core: `SourceControlSet::seek_report`, the `seek_at` doc (D7), and a new - `SourceControlError::ReportBusy` (`source.report.busy`, backpressure). -- control-plane: `SessionState::seek_report` runs `service()` first (amendment), reads the newest - providers; `pub use source::SourceSeekReport`. -- capi: `miso_engine_v1_source_seek_report`, `SOURCE_SEEK_REPORT_SIZE` 64, - `FEATURE_SOURCE_SEEK_REPORT` = `1 << 9` (mask 1023), header thread table, service - paragraph and a two-sentence note, both smoke programs, the frozen list (19 symbols) and - `docs/C_ABI_V1_QUALIFICATION.md`. - -**Departures.** - -- The C struct is `miso_engine_v1_seek_report`, not D6's `miso_engine_v1_source_seek_report`: C - and C++ give a typedef and a function one name space, so D6's two names do not compile - (`redeclared as different kind of entity`). The size macro and feature bit keep D6's names. -- D3 read: a block that changes only a counter rewrites the two counters and keeps the - generation's `first_sample` and `source_frame`, so `(g, s, f)` always names the generation's - first block (the host workflow of gate 3). -- A clockless `begin_block` (tests and `read_block` only) continues its own clock: the block after - a block at `t` is at `t + quantum`, from 0. -- No re-export in `crates/capi/src/runtime/mod.rs`: the export reads the report's fields without - naming the type. -- Mechanical edits outside Authorized paths: `crates/host-core/tests/source_diagnostics.rs` gains - the `ReportBusy` row (its exhaustive table does not compile without it), and - `tools/realtime-policy/src/policy.rs` raises the floors to the tree's counts (33 files, 151 - regions), as its comment requires. - -**Gates.** - -1. `capi` `runtime::tests::a_plain_seek_is_reported_at_the_block_that_applied_it`: `(2, 768, - 4096, 0, 0)` after block 6, and `(1, 0, 0, 0, 0)` between the seek call and block 6. -2. `a_late_anchored_seek_reports_its_frame_plus_the_lateness`: `(2, 1280, 768, 0, 0)`, and block - 10 is bit-identical to block 6 (frames 768 on). -3. and 4. `a_scrubbed_stem_and_a_stem_started_from_its_report_stay_aligned`: Y anchored at - `f + (A - s)`; blocks 10-15 bit-identical to a fresh session of the committed document fed both - stems from the aligned frames; X unchanged across the carrying swap and its underrun counter - moves after it; Y `(0, ..)` before the swap block, `(1, 896, 0, 0, held)` while held, then - `(2, 1280, 4608, 0, 384)`. Source-level: `a_carried_consumer_continues_the_predecessor_audio_gap_free` - now also checks the report across the swap. -5. `source` `the_seek_report_carries_the_cumulative_held_and_underrun_frames`; also - `the_seek_report_names_the_block_and_frame_each_generation_started_at` and - `a_clockless_consumer_reports_consecutive_quanta`. -6. `report_separates_session_pcm_from_source_overhead` adds the row. `./target/release/audit capi`: - 0 allocations, 0 deallocations, 0 syscalls, `"total_violations":0`, `pcm_digest` - `4f52089e4a8cf83c` (unchanged). -7. `ffi::tests::seek_report_refusals_leave_out_untouched_and_name_their_rule`; `cargo test -p - source`, `-p engine --features engine/realtime-audit`, `-p host-core --features - host-core/test-support`, `-p capi`; `check-capi-abi.sh` and `--self-test`; `realtime-policy`; - `check-cross-targets.sh`; `check-workspace-policy.sh`; clippy of the touched crates; fmt; doc. - -**Measured.** `seek_report_bytes` is 64 per ring on 64-bit targets, 56 on wasm32 (`Arc`'s two -counters plus six `u64` words). host-web's `retained_ceilings` stays inside its budgets. - -**Successors.** The browser export is #1332 and #1293 (Non-goals). `observe.rs` onto -`seqlock.rs` is #1482 D3. `source.id.invalid` on this entry point is #1350. - -**Successor owner (root ruling, 2026-10-16).** The browser export of the seek report is #1293 D6 -(batch 16) alone, with a parity row against the C ABI struct, independent of #1332 (which waits -on the owner's iOS test). Until #1293 lands, the browser cannot seek through the core, so it has -no seek to report; the C ABI reads the report today. #1293's amendment of 2026-10-16 records it. - -Bit move: bit 9, because #1323 took bit 8 first; merge of 15b into 15c. diff --git a/.github/ISSUE_SPECS/1317-document-the-seek-contract-and-the-c-abi-growth-rule-in-the-header.md b/.github/ISSUE_SPECS/1317-document-the-seek-contract-and-the-c-abi-growth-rule-in-the-header.md deleted file mode 100644 index 1a429ff18..000000000 --- a/.github/ISSUE_SPECS/1317-document-the-seek-contract-and-the-c-abi-growth-rule-in-the-header.md +++ /dev/null @@ -1,253 +0,0 @@ -# Document the seek contract and the C ABI growth rule in the header - -Stream B of decision 15 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`, D15-12). -Code anchors verified on `main` at `6fb211594`. - -## Product outcome - -A C host author can write correct seek code and correct version checks from -`miso_engine_v1.h` alone. The header states the seek contract in full (the source-read clock and its -origin, held behaviour, replacement, the in-flight BACKPRESSURE string, displacement by a -transaction, far-past anchors, choosing the anchor, the source-rate assumption, and how a plain -seek's landing is reported) and the ABI growth rule (a feature bit per added symbol or named -struct word, none for a new value of an existing enum, never compare the mask with `==`, and the -bit does not protect a directly linked host). The repository's own C smoke -test stops modelling the `==` comparison it tells hosts never to write. - -## Context - -- The seek text today is the paragraph "Starting an added stem in time" - (`crates/capi/include/miso_engine_v1.h:96-108`). It anchors on "the clock - miso_engine_v1_render_f32_planar takes" (`:98-99`), which D1-a replaces by the plan's source-read - clock (#1316 D1). It does not state held behaviour for an already playing source, replacement, - the one-seek-in-flight refusal, displacement, far-past anchors, the clock origin or the rate - assumption. (#1318 deletes its false clause "those blocks count as source underruns".) -- Behaviour to document, each verified in code: - - Clock origin: a plan starts at absolute sample 0 (`crates/engine/src/realtime/plan.rs:582`), - renders contiguous quanta (`render_contiguous`, `:870-882`), and a swapped-in plan continues - the outgoing clock (`crates/engine/src/realtime/plan_exchange.rs:418`). Anchors must be quantum - multiples (`crates/source/src/lib.rs:771-774`). - - Held: while a seek waits for its anchor, queued PCM of the playing generation plays, then the - source holds silence (`crates/source/src/lib.rs:1108-1114`; reported as held by #1318). - - Replacement: a newer command, plain or anchored, replaces a held one - (`crates/source/src/lib.rs:1325-1335`). - - In flight: the command queue has one slot; a second seek before render pops the first is - refused as `MISO_ENGINE_V1_BACKPRESSURE` with `source.seek.backpressure` - (`crates/source/src/lib.rs:776-785`; string at `crates/host-core/src/source.rs:99`). A held seek - has been popped, so it does not block the next one. - - Displacement: a persisting source's held seek moves with its consumer across a swap; a source - a transaction changes restarts in a new ring at generation 1 and its held seek is discarded with - the old ring; a removed source refuses as `source.id.unknown` - (`miso_engine_v1.h:85-94`). - - Far past: an anchor already rendered applies at the next block at `F` plus the lateness - (`crates/source/src/lib.rs:1355-1363`); a frame that lateness carries past the region end - renders silence as end of region, neither underrun nor held (`:2229-2236`). - - Rate: lateness is added in source frames; that is exact because every chunk must be at the - session rate (`source.rate.mismatch`, `crates/host-core/src/source.rs:167-172`). - - Plain seek landing: reported by `miso_engine_v1_source_seek_report` (#1316). -- Growth rule today: `docs/C_ABI_V1_QUALIFICATION.md:112-113` says the host checks the bit "since - a library older than #1275 does not export it", which implies the bit protects a directly linked - host; it does not (the load fails first). `crates/capi/tests/c/abi_smoke.c:120-126` requires - `capabilities.feature_mask != MISO_ENGINE_V1_FEATURE_MASK` to be false, the `==` pattern. The - exact-mask equality is already asserted in Rust (`crates/capi/src/ffi.rs:1458-1472`, - `crates/capi/src/abi.rs:482-486`). - -## Decisions frozen for this slice - -- **D1. Seek paragraph.** Replace `miso_engine_v1.h:96-108` with one paragraph per topic, in this - order: the source-read clock ("equal to the render clock in this version"; origin 0 at compile; - advances one quantum per render; a plan swap continues it); `seek` (applies at the next block render - begins; where it landed is the seek report); `seek_at` (quantum-multiple anchor, `source.seek.anchor_unaligned`); - choosing `A` (a few quanta past the last rendered block, enough to cover the host's decode and - submit latency); held behaviour; replacement; the in-flight refusal and its string; displacement - by a transaction; far-past anchors and the region end; the rate assumption. Every statement names - its diagnostic string where one exists. No statement promises anything the code does not do. -- **D2. Growth rule** in the header, beside `MISO_ENGINE_V1_ABI_VERSION` (`:111`): - `ABI_VERSION` changes only on a break. Each added symbol and each former reserved struct word - given a name gets its own feature bit, the next free one when it merges. A new value of an - existing enum or result code, and an in-place V1 protocol field, takes no bit (decision 15, - D15-3 recorded resolution), so hosts tolerate enum values they do not know and treat an - unknown result code as a failure. Hosts test each bit they need with `&`, tolerate unknown - bits, and never compare `feature_mask` with `==`. A feature bit protects only a host that - resolves the symbol at run time; a host that links a symbol directly fails to load against an - older library before it can test anything. -- **D3. Qualification record.** `C_ABI_V1_QUALIFICATION.md:112-113` is corrected to D2's wording, - and a short "ABI growth" paragraph states D2 once for the record. -- **D4. Smoke test.** `abi_smoke.c` drops the `!= MISO_ENGINE_V1_FEATURE_MASK` comparison and - instead requires each feature bit the header defines, one `&` test per `MISO_ENGINE_V1_FEATURE_*` - symbol, never a bit number. The exact-mask check stays in the Rust tests named above. - -## Deliverables - -1. D1 and D2 in `crates/capi/include/miso_engine_v1.h` (comment text only; no declaration changes). -2. D3 in `docs/C_ABI_V1_QUALIFICATION.md`. -3. D4 in `crates/capi/tests/c/abi_smoke.c`. - -## Authorized paths - -- `crates/capi/include/miso_engine_v1.h`, `crates/capi/tests/c/abi_smoke.c` -- `docs/C_ABI_V1_QUALIFICATION.md` - -## Non-goals - -- Any behaviour change; any new symbol. The browser SDK's seek documentation (stream H). - -## Objective gates - -1. **Header still compiles as both languages, at the strictest flags.** `bash scripts/check-capi-abi.sh` - (C11 and C++17 consumers at `-Wall -Wextra -Werror -pedantic`, symbol set, smoke run) and - `bash scripts/check-capi-abi.sh --self-test`. -2. **Smoke test is a bit test.** Build the smoke test against a library whose capability report - clears one defined bit (the checker's existing mutation mechanism or a local patch, recorded in - the PR): it fails. Against a library that reports one extra unknown bit, it passes. -3. **Rust exact-mask tests unchanged and green:** `cargo test --locked -p capi`. -4. **Review gate (no automated test):** the reviewer checks every D1 statement against the code - anchor listed in Context. A test that greps the header is refused by AGENTS.md. -5. `bash scripts/check-workspace-policy.sh`, `cargo fmt --all -- --check`. - -## Test value - -- Gate 2 (amended `abi_smoke.c`): red if the shipped library stops reporting any bit the header - defines; it no longer goes red for an extra unknown bit, which is the host-tolerance behaviour - D2 requires. The library-side exact equality stays caught by `version_and_capabilities_are_exact`. -- No other test is added: the deliverable is prose, and prose is never the subject of a test. - -## Dependencies - -- *Anchor every seek on the plan's source-read clock* (#1316): the clock name and the seek report - the text documents. -- *Report held source blocks apart from underruns* (#1318): the held reporting the text documents. - -## Amendment (controls coordinator, batch 15 check, 2026-10-15) - -Checked against `228fca26c`. - -**Moved anchors.** The header grew by about 110 lines (the live-edit, solo, response-preview, -completion, service and engine-memory paragraphs). Each behaviour in Context still holds; only -the lines moved. - -- D1 replaces the paragraph "Starting an added stem in time" at - `crates/capi/include/miso_engine_v1.h:204-216` (was `:96-108`). Its clock clause is `:205-207`; - the false clause that #1318 deletes is `:214-215`. -- D2 goes beside `MISO_ENGINE_V1_ABI_VERSION` at `:219` (was `:111`). The feature bits are - `:244-252`; the mask is 255. -- Displacement is the paragraph "Sources across a structural transaction" at `:193-202` (was - `:85-94`). -- Clock origin: a new plan's clock is 0 at `crates/engine/src/realtime/plan.rs:834`; - `render_contiguous` is `:1172-1185`; a swapped-in plan continues the clock at - `crates/engine/src/realtime/plan_exchange.rs:779`. Anchors must be quantum multiples at - `crates/source/src/lib.rs:771-775`. -- Held: `begin_block_at` doc `crates/source/src/lib.rs:1106-1114`. Replacement: - `observe_seek_at_block_boundary`, `:1324-1346`. In flight: `try_seek`'s push and its - `Backpressure`, `:776-786`; the string is still `crates/host-core/src/source.rs:99`. -- Far past: `:1350-1365`. The region-end tests are `:2264-2310` - (`a_late_anchored_seek_past_the_region_end_is_the_end_of_region` and the on-time short-block - case). -- Rate: still `crates/host-core/src/source.rs:167-172`. -- Growth rule: `docs/C_ABI_V1_QUALIFICATION.md:112-113` is unchanged. The `==` model in - `crates/capi/tests/c/abi_smoke.c` is now `:221` (the block is `:215-223`). The Rust exact-mask - tests are `version_and_capabilities_are_exact` (`crates/capi/src/ffi.rs:1558`) and - `crates/capi/src/abi.rs:550-554`. - -**Code notes for the implementer.** - -- The smoke test also pins header constants at compile time (`abi_smoke.c:22-27`, for example - `ABI_ASSERT(MISO_ENGINE_V1_FEATURE_MASK == 255)`). They pin the header, not the library's - report, and `scripts/check-capi-abi.sh --self-test` relies on the fixtures' pins (`:87-88`). - D4 covers the runtime check only. Keep the static pins. -- Gate 2: the checker's self-test mutates the staged header and the `nm` output. It cannot - change the library's capability report. Use the "local patch, recorded in the PR" route. -- Since #1348 the seek calls run the service step first (header `:148-153`). D1's `seek` - paragraph should not contradict that paragraph. A cross-reference is enough. -- "Equal to the render clock in this version" stays true after #1396: only a warm successor - (#1355) makes the offset nonzero in production. #1355 must amend this text when it lands. - Record that in #1355. - -**Dependencies on GitHub.** #1316 and #1318 are open. Both are in the batch-15/16 list. #1275, -#1314 and #1348 are closed. No dependency is outside the batch. - -**Decisions.** - -- D1, D3 and D4 hold against the code and decision 17. -- **B15-R3, root decision under owner delegation (2026-10-15): D2 keeps its text and adds two sentences: "A feature added as a - protocol message is announced by its protocol capability flag in the CAPABILITIES reply, not by - a feature bit. The registry, not this rule, governs protocol flags."** (D2 against decision - 17 C13.) The header states the two sentences and points to the registry for the rest. The - finding follows. Since decision 17, new features reach - the C ABI as protocol messages through `miso_engine_v1_submit_command`, with no new export - (C13). Each is announced by a protocol capability flag in the `CAPABILITIES` reply, not by a C - feature bit (header `:105`, `RESPONSE_GET`, "capability flag bit 14"). The protocol decoder - refuses an unknown capability flag (`docs/CONTROL_PROTOCOL_REGISTRY.md:13`), which is the - opposite of D2's "tolerate unknown bits" for `feature_mask`. D2 says nothing about protocol - flags. So a host author who reads only D2 can apply the C rule to protocol flags. Proposed - resolution: D2 keeps its text and adds two sentences: "A feature added as a protocol message - is announced by its protocol capability flag in the CAPABILITIES reply, not by a feature bit. - The registry, not this rule, governs protocol flags." Root decides whether the header states - the protocol rule or points to the registry. - -**Gates and commands.** `scripts/check-capi-abi.sh`, its `--self-test`, both C fixtures and -`scripts/check-workspace-policy.sh` exist. `cargo test --locked -p capi` runs both Rust -exact-mask tests. - -**Realtime-path edits.** None. This slice is header text, one doc and one C test. - -## Decision record: #1317 (batch 15c) - -**Shipped.** D1-D4 and B15-R3, comment text and one C test; no declaration, symbol or Rust change. - -- Header (`crates/capi/include/miso_engine_v1.h`): "Starting an added stem in time" and the two-line - seek-report note are replaced by ten paragraphs in D1's order: the source-read clock, seek, - anchored seek, choosing the anchor, held, replacement, one seek in flight, seeks across a - structural transaction, far-past anchors, rate, and where a seek landed. A new comment block - before `MISO_ENGINE_V1_ABI_VERSION` states the growth rule (D2) with B15-R3's two sentences. The - cross reference in the live-edit paragraph now points at the new paragraphs, and the #1310 - paragraph is rewrapped to 100 columns (one line was 135 wide); "placeholder #1699" became "issue - 1699 is the successor issue for one". -- `docs/C_ABI_V1_QUALIFICATION.md` (D3): the `seek_at` paragraph no longer implies the bit protects - a directly linked host and names the source-read clock; a paragraph records the seek contract and - one "ABI growth" paragraph states D2 once. -- `crates/capi/tests/c/abi_smoke.c` (D4): `reports_every_defined_feature` requires one `&` per - `MISO_ENGINE_V1_FEATURE_*` symbol (ten); the `!= MISO_ENGINE_V1_FEATURE_MASK` comparison is gone. - The compile-time pins are kept. - -**Statements checked against the code** (file, function): clock origin 0 and `render.time.discontinuity` -(`plan.rs` `render_contiguous`, `plan_exchange.rs` `adopt_absolute_sample`, `ffi.rs` render test); -quantum-multiple anchor and the check order (`source/src/lib.rs` `try_seek`); producer switches to -`g` at the call and refuses other generations (`validate_submission_metadata`); held, replacement, -late apply and region end (`observe_seek_at_block_boundary`, `begin_block_play`, `settle_block`, -tests `a_late_anchored_seek_past_the_region_end_is_the_end_of_region`); one command slot -(`command_capacity = 1`, `source.seek.backpressure` in `host-core/src/source.rs`); consumer carry -including `held_seek` (`adopt_sources`); report words and 64-attempt bound (`publish_seek_report`, -`seqlock.rs`); source rate equals the session rate (`prepare.rs` pushes `compiled.sample_rate()`). - -**Departures.** - -- The text says a seek takes effect "no later than the first block that begins after the call - returns", not "at the next block render begins": a producer that pushes a seek and its PCM - between a block's command pop and its data pop is observed by the acquire's re-observe in that - same block (#1274 MINOR-3). -- Far-past text says the seek applies in the block that pops it (not "the next block after it is - popped"), as `observe_seek_at_block_boundary` pops and applies in one block. -- The end-of-region sentence is conditional on a region the host closed with `end_of_region`; with - no end known the block is an underrun. -- Feature mask is 1023 (bit 8 landed with #1323, bit 9 with #1316), not the amendment's 255. -- #1320's prime has no host-visible API, so the header says nothing about it. #1396 has not landed, - so "equal to the render clock in this version" stands and #1396 is not named in the header. - -**Gates.** - -1. `bash scripts/check-capi-abi.sh`: ok (C11 and C++17 at `-Wall -Wextra -Werror -pedantic`, symbol - set, smoke run, shared and static); `--self-test`: ok. -2. Smoke is a bit test, by a local patch of `FEATURE_MASK` in `crates/capi/src/abi.rs`, reverted - after each run: clearing `FEATURE_SERVICE` makes `check-capi-abi.sh` exit 1; OR-ing in unknown bit - `1 << 9` passes. -3. `cargo test --locked -p capi`: 158 lib tests and every integration test pass (the Rust exact-mask - tests are unchanged). -4. Review gate: every D1 statement is checked above against its code anchor. -5. `bash scripts/check-workspace-policy.sh`, `cargo fmt --all -- --check`, - `RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --no-deps`: pass. - -**Successor work.** #1355 must amend the "equal to the render clock in this version" sentence (the -header's first seek paragraph, the qualification record and `SourceControlSet::seek_at`'s doc) when -a warm successor makes the source-read offset nonzero; the amendment asks for this to be recorded in -#1355, whose spec does not yet carry it. #1699 owns the counter the header says does not exist yet. diff --git a/.github/ISSUE_SPECS/1318-report-held-source-blocks-apart-from-underruns.md b/.github/ISSUE_SPECS/1318-report-held-source-blocks-apart-from-underruns.md deleted file mode 100644 index 8891b1f2c..000000000 --- a/.github/ISSUE_SPECS/1318-report-held-source-blocks-apart-from-underruns.md +++ /dev/null @@ -1,199 +0,0 @@ -# Report held source blocks apart from underruns - -Stream B of decision 15 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`, D15-12). -Code anchors verified on `main` at `6fb211594`. - -## Product outcome - -A block that a source renders as silence because the host asked it to wait (an anchored seek is -held until its anchor block) is reported as **held**, never as an **underrun**. Underrun keeps its -one meaning: the host did not deliver PCM it owed. A planned wait no longer looks like starvation -in the per-block report, the cumulative counters or the graph-wide observation flag that browser -spectrum windows carry. - -## Context - -- An anchored seek is popped and held in `PcmSourceConsumer::held_seek` - (`crates/source/src/lib.rs:1018`, `HeldSeek` at `:1022-1028`) until the block whose first sample - reaches its anchor (`observe_seek_at_block_boundary`, `:1323-1363`). While it is held, queued - PCM of the playing generation still plays, and with nothing playable the block underruns - (`begin_block_at` doc, `:1108-1114`). -- That underrun is counted in `begin_block_play`'s missing-PCM branch (`:1153-1176`): - `underrun_frames`, `underrun_events` and `SourceReadReport::underrun_event`. The report type is at - `:404-423`, the render-owner telemetry at `:438-448` and `:1290-1300`. -- The graph source driver folds `report.underrun_event` into the graph-wide - `GraphObservationValidity::source_underrun` (`crates/source/src/lib.rs:1722`; the type is - `crates/graph/src/lib.rs:2514-2522`), which host-core spectrum windows retain - (`crates/host-core/src/spectrum.rs:409`, `:448`). So a held block marks the window as starved. -- The header says of the wait before an anchor: "those blocks count as source underruns" - (`crates/capi/include/miso_engine_v1.h:106-107`). The C ABI exposes no underrun counter at all, - so the clause promises a report that does not exist. -- Existing tests that read underrun counts around held seeks: the anchored-seek tests near - `crates/source/src/lib.rs:2265-2330` (`consumer.underrun_events` asserted at `:2316`) and - `prepare_seek_holds_an_anchored_seek` (`:2197`). - -## Decisions frozen for this slice - -- **D1. Definition.** A missing-PCM block is **held** when `held_seek.is_some()` after this block's - observe step (the held seek did not apply at this block). Otherwise it is an underrun, as today. - The end-of-region rule is unchanged (past the end is neither). -- **D2. No bit moves.** Held changes classification only. The state machine (`next_frame` advance, - block retention, generation handling) is untouched, so every rendered sample is bit-identical to - today. -- **D3. Report fields.** `SourceReadReport` gains `held_frames: u32`, `held: bool`, - `cumulative_held_frames: u64` and `cumulative_held_blocks: u64`. A held block has - `underrun_frames == 0` and `underrun_event == false`. `PcmSourceConsumer` keeps the two saturating - cumulative counters; `SourceConsumerTelemetry` gains `held_frames` and `held_blocks`. -- **D4. Graph-wide flag.** The source driver sets `block_validity.source_underrun` only from - `report.underrun_event` and from a vacant entry (`:1715-1717`, unchanged). A held block sets - nothing; `GraphObservationValidity` gets no new field (it is stream A's type, and a held block is - not an invalidity). -- **D5. Disarm telemetry layout unchanged.** `copy_after_disarm_telemetry` (`:1803-1820`) keeps its - five slots per source; held counts are not added there. The C host reads held and underrun - counts through the per-source seek report of *Anchor every seek on the plan's source-read clock* - (#1316). -- **D6. Docs.** The host-core facade's `seek_at` doc ("reports an underrun", - `crates/host-core/src/source.rs:219-221`) is corrected by #1316, which owns that file's change. - Delete "and those blocks count as source underruns" from - `miso_engine_v1.h:106-107`. The full replacement text is #1317's. - -## Deliverables - -1. D1-D4 in `crates/source/src/lib.rs`, with the existing held-seek tests updated to the new split. -2. D6 in the header. - -## Authorized paths - -- `crates/source/src/lib.rs` -- `crates/capi/include/miso_engine_v1.h` (the one clause) - -## Non-goals - -- Exposing the counters through the C ABI (#1316) or the browser status (stream H). -- Changing underrun behaviour, adding a discard of behind-position blocks, or touching - `GraphObservationValidity`. - -## Objective gates - -1. **Held is not an underrun.** Unit test in `crates/source/src/lib.rs`: a consumer playing - generation 1 with no PCM queued; `try_seek(SourceCommand::SeekAt { generation: 2, .. })` anchored at block 4; render blocks 0..3 - with `begin_block_at`: every report has `held == true`, `held_frames == quantum`, - `underrun_event == false`; cumulative held blocks 4, underrun events 0. Block 4 applies the seek; - with generation 2's first block queued it plays (no held, no underrun); without it, block 4 is an - underrun (the seek applied, the host owes PCM). -2. **Old PCM still plays while held.** Same setup with generation 1 PCM queued for blocks 0..1: - blocks 0-1 copy PCM (neither held nor underrun), blocks 2-3 are held. -3. **Graph flag.** Driver test beside `graph_driver_forwards_underrun_and_seek_generation_facts` - (`:2658`): a held block leaves `observation_validity().source_underrun` false; a true underrun on - another source in the same block sets it. -4. **No bit moved.** The existing `crates/source` and `crates/host-core/tests/successor_swap.rs` - anchored-seek bit-identity tests pass unchanged (their PCM assertions untouched). -5. Commands: - - `cargo test --locked -p source` - - `cargo test --locked -p host-core --features host-core/test-support` - - `cargo run --locked --release -q -p realtime-policy` - - `bash scripts/check-workspace-policy.sh`, `cargo clippy --locked --workspace --all-targets -- -D warnings`, `cargo fmt --all -- --check` - -## Test value - -- Gate 1: red if a held block still counts as an underrun, or if the classification wrongly keeps - "held" after the seek applied (the block-4 starvation case must stay an underrun). -- Gate 2: red if held is decided by `held_seek` alone instead of "missing PCM while held", which - would hide playing PCM blocks as held. -- Gate 3: red if the driver still ORs held blocks into the graph-wide underrun flag. -- The amended assertions near `:2316` replace the old underrun-count expectations for held blocks; - no test is deleted. - -## Dependencies - -- none - -## Amendment (controls coordinator, batch 15 check, 2026-10-15) - -Checked against `228fca26c`. - -**Moved anchors.** Each fact in Context still holds; only the lines moved. - -- `held_seek` is still `crates/source/src/lib.rs:1018`; `HeldSeek` is `:1022-1027`. - `observe_seek_at_block_boundary` is `:1323-1365`. The `begin_block_at` doc is `:1106-1114`. -- The missing-PCM branch of `begin_block_play` is `:1154-1178` (the function is `:1135-1190`). - `SourceReadReport` is `:404-423`, `SourceConsumerTelemetry` `:439-448`, `telemetry()` - `:1290-1299`. -- The graph source driver's block entry is `:1752-1785`. The vacant-entry branch is `:1765-1769`; - the fold of `report.underrun_event` is `:1773`. -- `copy_after_disarm_telemetry` is `:1854-1875`. It still writes five slots per source. -- `GraphObservationValidity` is `crates/graph/src/lib.rs:2927-2940`. -- Spectrum windows moved into the core service (#1591): the flag is retained in - `crates/host-core/src/spectrum_service.rs` (`:496`, `:517`, `:1206`), not `spectrum.rs`. The - browser copies it into its window record at `hosts/host-web/src/ffi.rs:1048` and `:1174`. No - code changes there: a held block simply stops setting the flag. -- The header clause is `crates/capi/include/miso_engine_v1.h:214-215` ("and those blocks / count - as source underruns"). -- The host-core facade's `seek_at` doc is `crates/host-core/src/source.rs:209-222`; "reports an - underrun" is `:222`. -- Tests: `prepare_seek_holds_an_anchored_seek` is `:2245`. The anchored-seek tests are now - `:2160-2445`. The held-block underrun assertion that this slice changes is `:2364` - (`assert_eq!(consumer.underrun_events, u64::from(anchored))` in - `a_seek_whose_pcm_arrives_inside_the_block_window_keeps_its_pcm`). The region-end tests are - `:2264-2310`. `graph_driver_forwards_underrun_and_seek_generation_facts` is `:2748`. - -**Decisions.** - -- D1-D6 hold against the code and decision 17. -- D1, made precise. `begin_block_play`'s acquire step can re-observe a command and set or apply - a held seek (`acquire_current_block`, `:1394-1440`, #1274 MINOR-3). So read `held_seek` in the - missing-PCM branch, after the acquire. That is the "after this block's observe step" of D1. -- The missing-PCM branch also advances `next_frame` by one quantum while held. D2 keeps that, so - no bit moves. - -**Dependencies on GitHub.** None. Downstream and open: #1316, #1317, #1320, #1387, #1537 and -#1640. `crates/source/src/lib.rs` is a hot file: #1640's hazard note orders stream B's source -slices (#1316, #1318, #1320) before it. - -**Gates and commands.** Gate 4's tests exist (`crates/host-core/tests/successor_swap.rs`, -`anchored_swap_run`, `:629-830`). That file also compares spectrum windows' `source_underrun` -between a run and its reference (`:9438`). Both runs hold the same way, so it stays green. Every -named command exists. - -**Realtime-path edits.** None under `crates/engine/src/realtime`. The change is on the source -consumer's render path in `crates/source/src/lib.rs`. - -## Decision record: #1318 (batch 15b) - -**Shipped.** D1-D4 and D6 in `crates/source/src/lib.rs` and the one header clause. - -- `begin_block_play`'s missing-PCM branch reads `held_seek` after the acquire step (the batch 15 - precision of D1). Held: the missing frames go to new `held_frames` / `held_blocks` counters and - `underrun_*` stays untouched. Otherwise it is an underrun as before. `next_frame` still advances by - one quantum in both cases, so no rendered bit moves (D2). -- `SourceReadReport` gains `held_frames`, `held`, `cumulative_held_frames`, `cumulative_held_blocks`; - `SourceConsumerTelemetry` gains `held_frames`, `held_blocks` (D3). Held frames use the same - region-bounded count as underrun frames (a held block past the end is neither, unchanged). -- The graph driver needed no change: it folds only `report.underrun_event` and the vacant entry, so a - held block sets nothing (D4). `GraphObservationValidity` and `copy_after_disarm_telemetry` are - untouched (D5). The browser spectrum record and the C ABI read nothing new, so both hosts behave the - same: a held block stops marking spectrum windows as starved on both. The held counters reach hosts - with #1316 (C ABI) and #1537 (browser status), which are the non-goals here. -- Header: "and those blocks count as source underruns" deleted (`miso_engine_v1.h`, lines ~250-253, - rewrapped at 100 columns). #1317 owns the full replacement text. -- D6 facade doc (`crates/host-core/src/source.rs` `seek_at`) left to #1316 as the spec says. - -**Departures.** None. The B15-R/B15-Q rulings the task text names are not in this spec file. - -**Gates -> tests** (all in `crates/source/src/lib.rs`): - -1. `a_wait_for_an_anchor_is_held_and_starvation_after_it_is_an_underrun` (both the delivered and the - undelivered case). -2. `queued_pcm_of_the_playing_generation_plays_while_a_seek_is_held`. -3. `graph_driver_does_not_flag_a_held_block_as_an_underrun` (held source plus a starved second source - in one block). -4. Updated, not deleted: `a_primed_block_waits_for_its_anchor` and - `a_seek_whose_pcm_arrives_inside_the_block_window_keeps_its_pcm` now assert held, not underrun. - -**Red evidence** (temporary mutations, restored): classification forced to never-held turns five tests -red (the three new ones plus the two updated); the driver also OR-ing `report.held` turns only gate 3 -red; deciding played-vs-missing by `held_seek` alone turns gate 2 and -`prepare_seek_holds_an_anchored_seek` red. - -**Measured numbers.** None re-pinned. `TransferBlock` is unchanged; the consumer gains two `u64` -fields, with no resource-report row that counts them. diff --git a/.github/ISSUE_SPECS/1319-test-held-seeks-across-swaps-and-supersession-and-add-a-seek-to-audit-capi.md b/.github/ISSUE_SPECS/1319-test-held-seeks-across-swaps-and-supersession-and-add-a-seek-to-audit-capi.md deleted file mode 100644 index b5ae34426..000000000 --- a/.github/ISSUE_SPECS/1319-test-held-seeks-across-swaps-and-supersession-and-add-a-seek-to-audit-capi.md +++ /dev/null @@ -1,262 +0,0 @@ -# Test held seeks across swaps and supersession, and add a seek to audit capi - -Stream B of decision 15 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`, D15-12, D15-9, D15-17). -Code anchors verified on `main` at `6fb211594`. - -## Product outcome - -An acknowledged `seek_at` is never dropped by a plan swap or by a candidate that a newer structural -transaction supersedes: committed tests prove that a held seek moves with its source's consumer -across a swap, survives a supersession while it is still queued, and carries on from a candidate -render already took. The release C ABI audit applies one anchored seek inside its audited render -scope, so the held-seek render path is under the zero-allocation, zero-syscall gate. - -D15-12's other tooling items (the `source.id.invalid` diagnostic, the #1293 D4 trap, the off-grid -lateness assertion, test-only untimed reads) are #1350; see Non-goals. - -## Context - -- The #1275 verifier's probe P5 passed on the candidate but was never committed (the decision-15 - adversary records it: `docs/handoffs/decision-15-2026-10-05/PLAN-2026-10-05-adversary-round1.md`, - "MINOR — untested ack path"; the plan item is `PLAN-2026-10-05-agreed.md:191`). The probe: - `seek_at(fixture-source, 2, 0, 1024)` before the transaction; the predecessor renders blocks 4-5 and holds the seek; the transaction - swaps at block 6; the carried consumer applies the seek at block 8; bit-identical to the reference - over 16 blocks. The #1275 ack argument relies on it: a seek the predecessor already popped - survives only as `held_seek` inside the moved consumer. -- No committed test holds a seek across a swap. The host-core helper asserts - `seek_before >= SWAP_BLOCK` (`crates/host-core/tests/successor_swap.rs:635`). The C ABI test - `an_added_c_abi_source_starts_at_its_anchored_render_sample` (`crates/capi/src/runtime/tests.rs:1236`) - seeks only after the commit. The carry moves the whole consumer with `mem::swap` - (`crates/source/src/lib.rs:1880`), so `held_seek` (`:1018`) travels with it today; nothing pins - that. -- A seek on the newest committed session goes to the pending candidate's ring - (`SessionState::seek_at`, `crates/control-plane/src/control.rs:1671-1686`, via - `newest_providers_mut`). A persisting source's producer moves to the successor at commit - (`adopt_persisting`, `crates/host-core/src/source.rs:286`; called at `control.rs:1148-1155`). - Today a second structural transaction while a candidate waits is refused - (`control.rs:1095-1097`). *Supersede an unadopted candidate plan by compare-and-swap* (#1310) - replaces it by withdraw-then-prepare (#1310 D1, on *Let the control thread withdraw an - unadopted candidate plan*, #1343, and *Prepare a successor across a withdrawn candidate plan*, - #1344): a withdrawn candidate A is succeeded by B prepared against the running plan P0, with A's - own sources donated; a candidate render has taken is the base of B, as today. There is no - re-targeted carry. -- #1310's gate 2 already covers a held seek on a source the withdrawn candidate *added* (the - donated ring). Nothing covers a seek queued on a source that *persists* from P0 through A into - B: its ring stays P0's, its producer moves twice (into A's set at T1, into B's at T2), and its - command slot holds the acked seek through the withdrawal. -- `audit capi` (`tools/audit/src/capi.rs`) commits one structural transaction before call - `CALLS_BEFORE_TRANSACTION = 1` (`:55`, `apply_structural_transaction` at `:181-213`) and never - calls `seek_at`. CI runs it at `.github/workflows/qualification.yml:711-719` and validates the - JSON at `:720-760` (`pcm_digest` is format-checked only). - -## Decisions frozen for this slice - -- **D1. P5 as a C ABI test** in `crates/capi/src/runtime/tests.rs`, named - `a_held_anchored_seek_crosses_a_plan_swap`, built from the helpers of - `an_added_source_starts_at_its_anchor_at` (`:1113-1233`). At 48 kHz, quantum 128, with a - `source_ring_frames` of at least 8 quanta: submit generation 1 of the fixture source for blocks - 0-7; render blocks 0-3; `seek_at(fixture-source, 2, 0, 1024)`; submit generation 2 from frame 0; - render blocks 4-5 (the seek is held; queued generation 1 plays); commit a structural transaction - that keeps the source (add a track and its source); render the swap at block 6 and on to block 15. - The reference is a fresh session of the committed document fed generation 1 for blocks 0-7 and - generation 2 from frame 0 at block 8 on. Bit-identical over all 16 blocks (every block plays, as - `assert_bit_identical` requires), and `test_plan_carry_counts` shows one carried swap. -- **D2. Withdrawn candidate, persisting source.** Same file, - `a_queued_seek_on_a_persisting_source_survives_supersession`: the fixture source plays for blocks - 0-3. With no render in between: T1 (structural, keeps the source, changes another track's insert - quality); `seek_at(fixture-source, 2, 0, A)` with `A` four blocks ahead (queued in the ring's - command slot, not popped); T2 (structural, keeps the source, changes a third track's insert) - supersedes T1's withdrawn candidate. Then submit generation 2 from frame 0 (through the - producer that is now B's) and render to `A + 4` blocks. The source switches at `A`, - bit-identical to a twin that submits T1∪T2 as one transaction at the same point and seeks the - same way. #1310's test-support withdrawal counter shows one `Withdrawn` outcome. -- **D3. Taken candidate.** `a_held_seek_carries_from_a_taken_candidate`: T1 adds source `s2`; - `seek_at(s2, 2, 0, A)` and submit generation 2 from frame 0; render one block, so render claims - T1's candidate A and A's consumer pops and holds the seek. T2 (structural, keeps `s2`) commits. - Its service step (*Add miso_engine_v1_service for bounded control work between edits*, #1348 - D2) has promoted A, so B is prepared against A as the base (#1310 D1, the `Taken` outcome's - path) and carries A's consumer with its held seek. Render to `A + 4` blocks: `s2` enters at - `A`, bit-identical to a fresh session of T2's document fed `s2` from frame 0 at `A`. #1310's - withdrawal counters both stay 0. The racy `Taken` branch inside a withdrawal is #1310 gate 5's. -- **D4. Audit.** In `audit capi`, right after the structural transaction (control work, outside - every render scope): `miso_engine_v1_source_seek_at(session, "fixture-source", 2, A, A)` with - `A = 4 * QUANTUM_FRAMES`, then submit one generation-2 quantum from frame `A`. Assert both return - `RESULT_OK`. Call 4's audited render applies the held seek. The existing assertions (`replacements - == 1`, `(carried, carry_mismatches) == (1, 0)`, `snapshot.total() == 0`) stay. The JSON schema is - unchanged; `pcm_digest` moves, which CI checks only for format. - -## Deliverables - -1. D1 now; D2 and D3 after #1310 has landed (they exercise its withdrawal paths). -2. D4. - -## Authorized paths - -- `crates/capi/src/runtime/tests.rs` -- `tools/audit/src/capi.rs` - -## Non-goals - -- The `source.id.invalid` diagnostic, the #1293 D4 trap (a typed held preparation), the off-grid - lateness assertion and test-only untimed reads: *Tighten the seek entry points: source.id.invalid, - a typed held preparation, timed reads only* (#1350). - -## Objective gates - -1. D1, D2, D3 pass: `cargo test --locked -p capi`. -2. **P5 is red on the defect it names.** Record in the PR: with the carry patched to reset the - carried consumer's `held_seek` (or to rebuild the consumer), D1 fails at block 8. -3. Record in the PR: D2 is red with the withdrawal path patched to rebuild a persisting ring (or - clear its command slot) for B; D3 is red with B prepared against A's carried base instead of A - (so `s2`, which A added, is fresh in B). -4. **Audit.** `cargo build --locked --release -p audit -p capi && ./target/release/audit capi` - prints `"total_violations":0` and exits 0; `cargo test --locked --release -p audit`. -5. `bash scripts/check-workspace-policy.sh`, `cargo clippy --locked --workspace --all-targets -- -D warnings`, `cargo fmt --all -- --check`. - -## Test value - -- D1: red if a swap resets, drops or fails to carry a held seek; no committed test seeks before a - swap (`successor_swap.rs:635` forbids it). -- D2: red if supersession rebuilds a persisting source's ring or loses its queued seek while A is - withdrawn; #1310's gates seek only on a donated source. -- D3: red if the control plane treats a candidate render took as withdrawn and prepares B across - it, so B gets a fresh ring for `s2` and the held, acked seek is lost. -- D4: red (as a realtime violation) if applying a held seek on the render thread allocates, frees, - locks or makes a syscall; no audited render applies one today. - -## Dependencies - -- *Supersede an unadopted candidate plan by compare-and-swap* (#1310), for D2 and D3 and its - withdrawal-outcome counters. -- *Add miso_engine_v1_service for bounded control work between edits* (#1348), for D3's promotion - before T2. - -## Amendment (controls coordinator, batch 15 check, 2026-10-15) - -Checked against `228fca26c`. - -**Moved anchors.** Each fact in Context still holds; only the lines moved. - -- The decision-15 handoff files are unchanged: the adversary's "MINOR — untested ack path" is - `docs/handoffs/decision-15-2026-10-05/PLAN-2026-10-05-adversary-round1.md:460`, and the plan - item is `PLAN-2026-10-05-agreed.md:191`. -- The host-core helper's assertion is `crates/host-core/tests/successor_swap.rs:638` - (`anchored_swap_run`, `:631`). -- In `crates/capi/src/runtime/tests.rs`: `an_added_source_starts_at_its_anchor_at` is - `:1333-1455`, `an_added_c_abi_source_starts_at_its_anchored_render_sample` `:1456`, - `assert_bit_identical` `:1513`. `test_plan_carry_counts` is `crates/capi/src/ffi.rs:1256`. -- The carry's `mem::swap` is `crates/source/src/lib.rs:1931` (`adopt_sources`, `:1902-1938`). - `held_seek` is still `:1018`. -- `SessionState::seek_at` is `crates/control-plane/src/control.rs:3028-3041`; it runs the - #1348 service step first, then calls `newest_providers_mut` (`:2578`). `adopt_persisting` is - still `crates/host-core/src/source.rs:286`; the control plane calls it at `control.rs:1846-1849`. -- A second structural transaction while a candidate waits is still refused, now at - `control.rs:1742-1744` (`pending_providers` is a `Vec`, but this check keeps it at one). -- `tools/audit/src/capi.rs`: `CALLS_BEFORE_TRANSACTION` is `:52`, `apply_structural_transaction` - `:178-211`, `submit_source_chunk` `:213-251`, the run loop `:480-560`. The audit also commits a - live edit every 64 calls (`LIVE_EDIT_PERIOD`, `:39`, #1258). CI runs it at - `.github/workflows/qualification.yml:777-785` and validates the JSON from `:786`. - -**Code notes for the implementer.** - -- D2 and D3: decision 17 and its slices make more values live (trim, polarity, input filters, - sends, VCA). A value edit can take the live path and publish no candidate. So T1 and T2 must be - structural edits (insert quality, or adding or removing an insert or a track). Assert edit-path - 3 (rebuild) in each response, so that a later live flip turns the test red, not vacuous. -- D4: `submit_source_chunk` hard-codes generation 1 (`:220`). Give it a generation argument. The - structural transaction already submits the generation-1 block at frame 128 before the seek, so - the order stays right. The fixture source has 48000 frames, so frame `A = 512` is in its region. -- D4 runs the seek and the submit outside every render scope, like the transaction. The - seek call's service step is control work there too. - -**Dependencies on GitHub.** - -- #1348 is closed. D3's promotion runs in `SessionState::service` (`control.rs:1486`). -- #1343 is closed: the engine's withdrawal primitive exists - (`PlanPublisher::withdraw`, `crates/engine/src/realtime/plan_exchange.rs:552`). The control - plane does not use it yet. -- *Supersede an unadopted candidate plan by compare-and-swap* (#1310) is open and is not in the - batch-15/16 list. #1310 depends on *Prepare a successor across a withdrawn candidate plan* - (#1344), also open and not in the list. D2, D3 and the withdrawal counters need both. -- Batch order (root, 2026-10-15): batch 15a is the controls lane, #1344, #1398 and #1310. - #1324 left batch 15a (root ruling, 2026-10-15); it lands in a later batch after #1288, #1391, - #1363 and #1325. - The rest of batch 15 follows, so #1310 and #1344 land before this slice. - -**Decisions.** - -- D1 and D4 hold against the code and decision 17. -- D2 and D3 hold as designs, but they cannot run before #1310. -- **B15-R4, root decision under owner delegation (2026-10-15): keep #1319 whole. #1310 and #1344 land first, in batch 15a.** - (Scope against #1310.) No successor issue is split off. #1319 closes on D1-D4 after #1310, - in the rest of batch 15. - The finding follows. Deliverable 1 says D2 and D3 come "after #1310 - has landed", and #1310 is outside the batch-15/16 list. Proposed resolution: split D2 and D3 - (with gate 3's D2/D3 records) into a successor issue that depends on #1310. #1319 then closes on - D1 and D4 in batch 15. The alternative is to keep #1319 open across batches, which the - "smallest closable slice" rule in AGENTS.md argues against. - -**Gates and commands.** Every named command, package and file exists. Gate 4's -`cargo test --locked --release -p audit` is also run by CI (`qualification.yml:775-776`). - -**Realtime-path edits.** None under `crates/engine/src/realtime`. D4 adds a held seek to the -audited render, so a defect on that path shows up as an audit violation, not as an edit here. - -## Amendment (controls coordinator, batch 15a fold, 2026-10-16) - -- **Batch placement.** "#1310 ... is not in the batch-15/16 list" and "#1344, also open and not in - the list" (Dependencies on GitHub) are void. #1344 and #1310 are in batch 15a (with #1398), which - lands before this slice. - -## Decision record: #1319 (batch 15b) - -**Shipped.** D1-D4 whole, in the two authorized paths. - -- `crates/capi/src/runtime/tests.rs`: `a_held_anchored_seek_crosses_a_plan_swap` (D1), - `a_queued_seek_on_a_persisting_source_survives_supersession` (D2) and - `a_held_seek_carries_from_a_taken_candidate` (D3), with helpers `submit_block_c`, `transact_c` - (returns the response's `EditPath`), `add_source_and_track`, `put_compressor` and - `render_fresh_reference`. -- `tools/audit/src/capi.rs`: right after the structural transaction (outside every render scope) - the audit calls `miso_engine_v1_source_seek_at(session, "fixture-source", 2, 512, 512)` and - submits one generation-2 quantum from frame 512; `submit_source_chunk` takes a generation. The - carried consumer pops the seek at the swap call (call 1), holds it for calls 2-3 and applies it at - call 4. The existing assertions are unchanged. - -**Measured.** `audit capi`: allocations 0, all violation counters 0, `replacements == 1`, -`(carried, carry_mismatches) == (1, 0)`. `pcm_digest` moved `bf7dcedd1d92621f` -> `4f52089e4a8cf83c` -(call 4 now plays the generation-2 quantum instead of an underrun's silence; CI checks the digest -for format only). The JSON schema is unchanged; no resource row exists in this audit. - -**Gates.** `cargo test --locked -p capi`: 151 lib tests and every integration test pass, among them -the three new tests. `cargo test --locked --release -p audit`: 33 pass. Clippy on `capi` and `audit` -(`--all-targets --all-features -D warnings`), `cargo fmt --all -- --check`, `RUSTDOCFLAGS='-D -warnings' cargo doc --locked --workspace --no-deps` and `scripts/check-workspace-policy.sh` pass. -The C header is untouched. - -**Departures from the text.** - -- D1 adds a muted track on the fixture source (`add_muted_track`), not a new track and source: - the spec's "keeps the source" is what the test needs, and the reference then compares with exact - zeros from the extra strip. -- D2 and D3 use inserts and a muted track that leave the reference comparable: D2 compares with - the twin, as the spec says (a fresh session's compressor state would differ from a swapped-in - one's); D3's T2 is a muted track, so its reference is a fresh session of T2's document. -- Both D2/D3 assert `EditPath::Rebuild` in each transaction's response (batch 15 check). -- The held counters (#1318) are consumer telemetry that the C ABI tests cannot reach (no - accessor from a plan or session), so the assertions are bit-identity and nonzero-reference, which - an unapplied or dropped seek breaks (the generation-2 blocks are discarded and the source is - silent from the anchor). -- D2 never feeds fresh sources, so the batch 15a guard (`drops_acked_fresh_source`) does not - arise: the fixture source persists from P0 and is never fresh in A. D3 withdraws nothing. - -**Red evidence (argued, not run: sprint mode forbids mutation runs).** D1: a carry that resets or -rebuilds `held_seek` leaves the successor with no seek at block 8, so the queued generation-2 -block is discarded as unobserved and blocks 8-15 are silent against a playing reference. D2: a -supersession that rebuilds the persisting ring (or clears its command slot) loses the seek or the -queued blocks the same way; the counters pin `(withdrawn, superseded, taken) == (1, 1, 0)`. D3: -preparing B across a taken A makes `s2` fresh in B, so its held seek and queued generation 2 are -lost and `s2` is silent from block 8; the counters pin `(withdrawn, taken) == (0, 0)`. - -**Successor work.** Expose the consumer's held counters through a test accessor (or the counter -snapshot) so a C ABI test can assert `held_blocks` directly. diff --git a/.github/ISSUE_SPECS/1320-let-a-source-consumer-check-and-replay-its-next-blocks-for-a-prime.md b/.github/ISSUE_SPECS/1320-let-a-source-consumer-check-and-replay-its-next-blocks-for-a-prime.md deleted file mode 100644 index b1bcb9768..000000000 --- a/.github/ISSUE_SPECS/1320-let-a-source-consumer-check-and-replay-its-next-blocks-for-a-prime.md +++ /dev/null @@ -1,315 +0,0 @@ -# Let a source consumer check and replay its next blocks for a prime - -Stream C of decision 15 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`, D15-8 (round-5 amendment), D15-17). -Slice of *Grow latency during playback by adopting a primed warm successor* (#1287). Code anchors -verified on `main` at `6fb211594`. - -## Product outcome - -Render can adopt a warm successor whose sources read `P` frames further ahead (#1355). For that it -needs two things from each carried source consumer, and this issue adds both: -- a **read-only readiness check**: in the block before it claims, render asks whether the - consumer would play each of its next `k + 1` blocks from queued PCM, with no underrun, no - discard and no command, where `P = k * quantum`; -- a **prime**: in the adoption block, the consumer replays its next `k` blocks exactly as it - would have played them on the predecessor's own schedule, without observing any new command. - -Each ring also gets a `prime_required` flag that the control thread sets for the rings a warm -successor carries. Nothing is copied, no block is shared, and no second reader exists. - -## Context - -- A source ring is two block queues of `Box` (`crates/source/src/lib.rs:450`), - a data queue (producer to consumer) and a recycle queue, plus a one-slot command queue - (`prepare_at_source_frame`, `:568`; the queues at `:578-588`). The ring allocates - `transfer_block_count + 1` blocks (`RETAINED_TRANSFER_BLOCKS`, `:654`). -- The queues are the engine's SPSC (`bounded_spsc_move`, `crates/engine/src/realtime/spsc.rs:249`). - Its consumer can count what is queued (`available_at_entry`, `:420`) and pop (`try_pop`, `:440`), - but cannot read a queued item without popping it. -- `PcmSourceConsumer` (`crates/source/src/lib.rs:993`) keeps `next_frame`, an optional prefetched - `current` block, the `played` block and a `held_seek` (`:1018`). `begin_block_at(first_sample)` - (`:1115`) first observes the command queue (`begin_block_observe`, `:1127`; - `observe_seek_at_block_boundary`, `:1323`), then acquires and plays (`begin_block_play`, `:1135`). -- `acquire_current_block` (`:1389`) returns at once while `current` is held (`:1390-1392`). It pops - blocks, discards stale or behind-`next_frame` ones, and keeps a held seek's first block as the - pending `current`. A popped block of an unobserved generation makes it observe the command queue - (`:1400-1407`). -- When no block matches `next_frame`, `begin_block_play` underruns. It still advances `next_frame` - by a quantum (the underrun branch `:1154-1178`; the advance `:1164-1168`). An underrun inside a - prime would therefore move the consumer's frame map, so the prime must be impossible to underrun. -- Host chunks are exactly one quantum, except a short end-of-region chunk - (`validate_submission_metadata`, `:924`). A short block's tail is zeroed in `play` (`:1466`). -- The graph driver calls `consumer.begin_block_at(first_sample)` per source each block - (`:1721`, in `begin_block`, `:1701`) and lends played planes (`played_planes`, `:1793`). -- `crates/source` has no `bench-support` dev-dependency today (`crates/source/Cargo.toml`). The - engine SPSC loom tests are named `spsc_loom_*` and run in the CI step at - `.github/workflows/qualification.yml:677-678`. - -## Decisions frozen for this slice - -- **D1. `peek(i)` on the SPSC consumer.** `Consumer::::peek(&self, i: usize) -> Option<&T>` - in `crates/engine/src/realtime/spsc.rs`, inside its realtime-policy region. - - It loads the producer cursor once (`Acquire`), as `available_at_entry` does, and returns the - `i`-th queued item from this consumer's cursor, or `None` when `i` is not below that count. - - It changes no cursor, cache or counter. - - It is sound because the producer writes only slots past the consumer's cursor. A slot the - consumer has not popped is initialized, and nothing rewrites it until the consumer pops it. - - The borrow is tied to `&self`, so no pop can happen while it is held. -- **D2. Readiness.** `PcmSourceConsumer::prime_ready(&self, first_sample: u64, lead_blocks: u32) - -> bool`. - - `first_sample` is the source-read sample of the block render is about to run on the active - plan: `S + O`, where `O` is that plan's source-read offset (#1396). - - With `k = lead_blocks` and `q` the quantum, it returns `true` exactly when all of these hold: - 1. The command queue is empty (`available_at_entry() == 0` on the command consumer). - 2. No held seek's anchor is below `first_sample + (k + 1) * q`. A held anchor is always past - the last block's sample, so this is every held seek that would apply at one of the `k + 1` - blocks. It covers the window `[S + O, S + O + P + q)`, and also an anchor in - `(S + O - q, S + O)`, which applies late at `S + O`. - 3. For each `j` in `0..=k`, the block the consumer would play at `next_frame + j * q` is - queued and playable: - - either the region's end is reached there (`end_frame`, or a peeked end-of-region block's - end, is at or before that frame); - - or block `j` has the active generation and starts at exactly `next_frame + j * q`. - - Block `j` is taken in queue order: `current` first if it is held, then `peek(0)`, - `peek(1)`, and so on. A short block must carry `end_of_region`. - - Anything else is `false`. This includes a gap, a block of another generation, a block behind - `next_frame` that the consumer would discard, a held seek's pending `current`, or fewer queued - blocks than needed. Those cases clear as render keeps playing the predecessor, so `false` - only delays adoption. - - It reads only: atomics loaded with `Acquire` and the consumer's own fields. It changes no - state. It costs at most `k + 2` loads per ring, with no allocation, lock or syscall. - - It is exact within one render callback. Only render changes the consumer, and the producer can - only add blocks or a command. An added block keeps `true` true. A command pushed after the - check is not observed by the prime (D3) and is observed at the successor's first ordinary - block, on the new clock. -- **D3. The prime.** `PcmSourceConsumer::prime_block_at(&mut self, sample: u64) -> - SourceReadReport`. - - It runs `begin_block_observe` without the command pop: `end_block`, `flush_deferred_recycle` - and `generation_changed = false`. Then it runs `begin_block_play` with a clock that applies no - seek (`SeekClock::Hold`). - - So it plays, discards and recycles exactly as `begin_block_at(sample)` would. The difference - is that it observes no command, so the command queue and `held_seek` are unchanged. - - Precondition: `prime_ready(first_sample, k)` was `true` in this callback, and the calls are - `prime_block_at(first_sample + j * q)` for `j = 0..k`, in order. - - Under that precondition it cannot underrun: block `j` matches `next_frame` or the end is - reached. `acquire_current_block` never pops past block `j`, so it never meets an unobserved - generation and never re-observes a command (`:1400-1407`). - - A debug assertion checks `underrun_frames == 0` and that no command was popped. The played - planes are lent by `played_plane` until the next `&mut self` call, as for any block. -- **D4. `prime_required`.** - - Ring preparation allocates one shared `AtomicBool` beside the queues, `false`, and counts it - in the resource report. - - `PcmSourceProducer::prime_flag(&self) -> PcmSourcePrimeFlag`, and the same on - `HostChunkProvider`, hand the control thread a handle. A handle is an `Arc` clone, taken at - preparation, never on render. It has `set(&self, bool)` (`Relaxed`). - - `PcmSourceConsumer::prime_required(&self) -> bool` loads it (`Relaxed`). - - The ordering comes from the plan mailbox. Control stores the flags before the `Release` that - publishes the candidate, and render loads them after its `Acquire` of the cell. #1311 D2's - generation check on the claim makes a read across a withdraw-and-republish harmless. #1355 - owns when control sets and clears them. -- **D5. Acked-batch question.** No queue changes and no command is consumed or dropped. Readiness - reads only. The prime plays blocks the consumer would play anyway, in the same order, and leaves - every command queued. An ack can never precede a drop. - -## Deliverables - -1. D1 in `crates/engine/src/realtime/spsc.rs`, with unit tests and one loom test named - `spsc_loom_peek_*`. -2. D2-D4 in `crates/source/src/lib.rs`. -3. The resource report gains the `prime_required` row. Every test that asserts the source ring's - report is updated in the same PR. -4. `bench-support` as a `crates/source` dev-dependency, for gate 6. - -## Authorized paths - -- `crates/engine/src/realtime/spsc.rs` (`peek` and its tests only) -- `crates/source/src/lib.rs`, `crates/source/Cargo.toml`, `crates/source/tests/prime.rs` (new), - `Cargo.lock` -- The source resource-report assertions in `crates/source/src/lib.rs` tests, - `crates/host-core/tests/` and `crates/capi/tests/` - -## Non-goals - -- No graph, plan-exchange or host-core change. Calling readiness before the claim, the prime in - the source section, and setting the flags are #1355's. -- No change to seek semantics (stream B, #1316-#1319). No ring sizing (#1406). - -## Objective gates - -Gates 2-4 run against a ring fed blocks whose samples encode `(frame, channel)`, with quantum 128 -and `k = 3`. - -1. **`peek`.** `peek(i)` returns the `i`-th queued item for every `i` below `available_at_entry()` - and `None` at it. Cursors, success counts and empty counts are unchanged by any number of - peeks. Run it across a wrap of the cursor space. The loom test (`spsc_loom_peek_*`): a producer - pushes 3 items through a 2-slot queue while the consumer peeks and pops. Every peeked value is - the one later popped. Command: - `CARGO_TARGET_DIR=target/ci/loom RUSTFLAGS='--cfg loom --check-cfg=cfg(loom)' cargo test --locked --release -p engine --lib spsc_loom` - (the existing CI step, `qualification.yml:678`). -2. **Readiness at exact boundaries.** Each case is checked at the boundary and one step past it: - - `k + 1` contiguous active blocks queued: `true`. Only `k`: `false`. - - The region ends inside the window: a short end block at `j = 1` and nothing after it gives - `true`. With the end block missing, `false`. - - A gap of one quantum at `j = 2`: `false`. - - A plain `Seek` or a `SeekAt` in the command queue: `false`. It is `true` again after one - ordinary block observes it, once the new generation's `k + 1` blocks are queued. - - A held seek anchored at `first_sample + (k + 1) * q - 1`: `false`. Anchored at - `first_sample + (k + 1) * q`: `true`. Anchored at `first_sample - 1`, held because the last - block began before it: `false`. - - Stale-generation blocks ahead of the active ones: `false` until ordinary blocks have - discarded them. - - After every call, the consumer's telemetry, `next_frame`, the queue counts and the command - queue equal a twin that made no call. -3. **The prime equals the blocks the consumer would have produced.** Two rings, fed identically. - On one, `k` calls `prime_block_at(s + j * q)`; on its twin, `k` calls - `begin_block_at(s + j * q)`. Every played plane, every `SourceReadReport` and the telemetry are - bit-identical. So are the next 8 ordinary blocks on both. Also run it with the region ending at - `j = 1`, and with a generation that a seek began 2 blocks earlier. -4. **The prime observes no command.** After a `true` readiness, push a `Seek` and then prime. - The primed planes equal gate 3's. The command is still queued, and the next - `begin_block_at` applies it. -5. **Flag.** A new ring reads `prime_required() == false`. After `prime_flag().set(true)` the - consumer reads `true`, and after `set(false)`, `false`. -6. **Realtime.** `prime_ready` with `k = 16` and 16 `prime_block_at` calls make zero allocations - and frees, measured with `bench_support::alloc`'s current-thread counters after warm-up. -7. Commands: - - `cargo test --locked -p engine`, `cargo test --locked -p source` - - `cargo test --locked -p host-core --features host-core/test-support`, `cargo test --locked -p capi` - - `cargo run --locked --release -q -p realtime-policy`, `cargo test --locked -p realtime-policy` - - `bash scripts/check-workspace-policy.sh`, `bash scripts/check-cross-targets.sh` - - `cargo clippy --locked --workspace --all-targets -- -D warnings`, `cargo fmt --all -- --check` - -## Test value - -- Gate 1: a `peek` that indexes from the cached producer cursor, or that writes the consumer - cursor, returns a stale item or loses one. Red. -- Gate 2: a check that ignores `current`, the command queue or the held-seek window lets the - prime underrun or skip a seek. Red at the matching boundary. -- Gate 3: a prime that uses its own frame arithmetic instead of `begin_block_play`'s accept rule - differs by a plane or a report. Red. -- Gate 4: a prime that pops the command applies a seek inside the window, out of schedule. Red. -- Gate 5: a flag that is not shared between the handle and the consumer reads `false` on render. - Red. -- Gate 6: a readiness walk that collects blocks into a buffer allocates. Red. - -## Dependencies - -- *Anchor every seek on the plan's source-read clock* (#1316): stream B's edits to - `crates/source/src/lib.rs` land first. -- *Report held source blocks apart from underruns* (#1318). -- *Test held seeks across swaps and supersession, and add a seek to audit capi* (#1319). - -## Amendment (controls coordinator, batch 15 check, 2026-10-15) - -Checked against `228fca26c`. - -**Anchors.** -- Unchanged: `TransferBlock` (`crates/source/src/lib.rs:450`), `prepare_at_source_frame` - (`:568`), `RETAINED_TRANSFER_BLOCKS` (`:654`), `validate_submission_metadata` (`:924`), - `PcmSourceConsumer` (`:993`), `held_seek` (`:1018`), `begin_block_at` (`:1115`), - `begin_block_observe` (`:1127`), `begin_block_play` (`:1135`) and - `observe_seek_at_block_boundary` (`:1323`). -- Moved: `acquire_current_block` is `:1394`. Its early return is `:1395-1397`, its pop of an - unobserved generation `:1408-1411`, and the observe after it `:1431`. It pops at most - `transfer_block_count` blocks per call (`:1398-1401`). -- The underrun branch is `:1154-1177`; the `next_frame` advance is still `:1164-1168`. The - short-block zeroing in `play` is `:1518-1527`. -- Graph driver: `begin_block` is `:1752`, its `consumer.begin_block_at` `:1772`, and - `played_planes` `:1844`. -- SPSC (`crates/engine/src/realtime/spsc.rs`): `Consumer` `:217`, `bounded_spsc_move` `:250`, - `available_at_entry` `:421`, `try_pop` `:441`. The consumer's realtime region is `:296-460`. -- `SeekClock` (`:1031`) already has `Hold`, and `prepare_seek` (`:1049`) uses it. D3 needs no new - clock. -- The loom CI step is now `.github/workflows/qualification.yml:734-735`. The command is unchanged. - -**Already done.** `bench-support` is a `crates/source` dev-dependency -(`crates/source/Cargo.toml:18`). Deliverable 4 is done. `crates/source/Cargo.toml` and -`Cargo.lock` need no change. - -**What landed around this slice.** -- #1311 (closed) added the plan-side hook. `PlanAdoption::Primed { not_before, lead_blocks }` - (`spsc.rs:532`) asks `PreparedPlanExecutor::prime_ready(block_start, lead_blocks)` - (`crates/engine/src/realtime/plan.rs:349`, default `false`). This slice's - `PcmSourceConsumer::prime_ready` is the per-ring check that #1355 calls from that hook. - `block_start` is a render sample, and #1355 adds the source-read offset `O` (#1396). -- Since #1639 (code landed; issue still open), the graph's block entry reads the jump lengths - (`crates/graph/src/lib.rs:4824-4826`) and then calls `source_set.begin_block` (`:4844`). Where - the prime goes in that entry is still #1355's. -- #1681: a ring has one or two channels. No effect here. - -**Decisions.** D1-D5 hold against the code. Decision 17 changes nothing here: source transport -is not a control. - -**Authorized paths and gates.** -- `scripts/check-realtime-policy.sh` is gone. Gate 7's `cargo run ... -p realtime-policy` still - works. If D1-D3 add a marked region instead of using an existing one, `marked_regions_floor` - (`tools/realtime-policy/src/policy.rs:227`) goes up. That file joins the paths for that line - only. -- The browser's resource record carries `source_overhead_bytes` (`hosts/host-web/src/lib.rs:1252`). - D4's flag changes it. `hosts/host-web/src/tests.rs` and `hosts/host-web/tests/` join the paths - for any assertion of that row. -- `crates/source/tests/prime.rs` is new. Every other named file and script exists. - -**Later work, not this slice.** #1640 (open) factors the seek state machine after this slice. -#1641 (open) adds a timeline consumer that carries like a source. Whether a warm adoption primes -the timeline consumer is for #1355 or #1641 to decide. - -**Dependencies.** #1316, #1318 and #1319 are open and in the batch. Nothing outside the batch. - -## Decision record: #1320 (batch 15c) - -**What shipped.** -- D1: `Consumer::peek(&self, i) -> Option<&T>` in `crates/engine/src/realtime/spsc.rs`, inside the - consumer's existing realtime-policy region. One `Acquire` load of the producer cursor (through - `available_at_entry`), no cursor, cache or counter change. A shared-access `sync::with` helper - (loom: `UnsafeCell::with`) sits beside `with_mut` in both `sync` shims; it is peek's read path. -- D2: `PcmSourceConsumer::prime_ready(&self, first_sample, lead_blocks)`. One command-queue load - and at most `lead_blocks + 1` peeks; the walk takes `current` first, then the queue; it returns - `true` as soon as the region's end is reached, because later blocks move no frame. -- D3: `PcmSourceConsumer::prime_block_at(&mut self, sample)`: `end_block`, - `flush_deferred_recycle`, `generation_changed = false`, then `begin_block_play(SeekClock::Hold)`. - Debug assertions check no underrun, no held frames and no command pop. -- D4: `prime_required` is one `Arc` per ring, made at preparation. - `PcmSourceProducer::prime_flag()` and `HostChunkProvider::prime_flag()` return a - `PcmSourcePrimeFlag` with `set(bool)` (`Relaxed`). `PcmSourceConsumer::prime_required()` loads it - (`Relaxed`). New resource row `SourceResourceReport::prime_required_bytes`. It is in - `overhead_bytes` and in the largest-allocation list. - -**Departures.** -- The prime's acquire stops before a block of an unobserved generation - (`CommandObservation::Withheld`, one `peek(0)` per pop in a prime only). D3 says the acquire - never pops past block `j`. That is not true at the region's end: there `begin_block_play` still - runs the acquire, and the acquire pops everything queued. A `Seek` pushed after a true - readiness, with its PCM behind the end block, would then make the prime observe it at once - (`:1408-1431`), and a plain seek would apply inside the window. The guard leaves that block - and its command queued. Ordinary blocks and `prepare_seek` pass `Allowed` and do not change. - Gate 4's end-at-`j = 1` case is red without the guard. -- The prime sets `next_source_read_sample = sample` before `begin_block_play(Hold)`, because - `Hold` reports on the consumer's own clock. Without this, a prime after a source-read clock - jump (#1639) would leave a later clockless block, and its seek report, on the old clock. - `the_prime_continues_the_callers_clock_across_a_jump` defends this. -- No new marked region. `peek`, `prime_ready` and `prime_block_at` are in existing regions, so - the realtime-policy floors do not change (151 regions in 33 files). -- No browser or C ABI surface: #1355 owns the callers. The browser's `source_overhead_bytes` - grows by the flag's bytes per ring. The host-web ceilings test still passes, so no ceiling moves. - -**Gates.** -1. `realtime::spsc::tests::peek_returns_each_queued_item_in_order_across_wraps_and_changes_nothing` - and the loom test `realtime::spsc::loom_tests::spsc_loom_peek_returns_the_item_later_popped` - (`spsc_loom` CI command: 15 passed). -2. `crates/source/src/lib.rs` `tests::prime_readiness::*` (11 tests). Each readiness call - asserts that telemetry, `next_frame`, `end_frame`, the data, recycle and command queue counts, - the pop counts, the held seek, `current`, the clock and the seek report did not change. Held - anchors inside or late in the window are set directly, because `try_seek` refuses unaligned - anchors. Gaps and stale blocks are pushed raw, because the host path refuses them. -3. `crates/source/tests/prime.rs::the_prime_equals_the_blocks_the_consumer_would_have_played` - (steady, region end at `j = 1`, a seek 2 blocks earlier). -4. `the_prime_observes_no_command` (steady and end at `j = 1`, with the seek's PCM queued). -5. `the_prime_flag_is_shared_between_the_handle_and_the_consumer`. -6. `readiness_and_sixteen_primes_allocate_nothing`: `k = 16`, 16 primes, 0 allocations and - 0 frees (current-thread counters). - -**Measured.** `prime_required_bytes` = 24 B per ring on 64-bit targets and 12 B on wasm32 -(three words). `audit capi` reports allocations 0 and pcm_digest `4f52089e4a8cf83c`, which is -unchanged. diff --git a/.github/ISSUE_SPECS/1323-reset-latency-floors-at-a-host-declared-discontinuity.md b/.github/ISSUE_SPECS/1323-reset-latency-floors-at-a-host-declared-discontinuity.md deleted file mode 100644 index 4cce5606e..000000000 --- a/.github/ISSUE_SPECS/1323-reset-latency-floors-at-a-host-declared-discontinuity.md +++ /dev/null @@ -1,430 +0,0 @@ -# Reset latency floors at a host-declared discontinuity - -Stream A of decision 15 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`, D15-8, D15-17; D15-8 (round-5 amendment)). -Code anchors verified on `main` at `6fb211594`. - -## Product outcome - -Latency does not ratchet up for the life of a session. *Keep every node's latency from dropping -during playback* (#1285) keeps a removed limiter's latency while audio plays. When the host -declares a discontinuity (it has stopped, or it is about to seek every source), the engine drops -every latency floor. The next render runs the session at its natural latency, and the plan -resource report says so. A pending warm successor is replaced by that plain rebuild. A declaration -with nothing to reset changes nothing. - -## Context - -- Floors are recorded in the predecessor's inventory and passed on to every successor (#1285 D2). - Without a reset they never come down. -- No C ABI call lets a host say "continuity is not needed". The render clock must stay contiguous - ("render.time.discontinuity", `crates/capi/include/miso_engine_v1.h:72-75`). Seeks are per - source (`miso_engine_v1_source_seek`, `miso_engine_v1_source_seek_at`, `:293-303`). On - `6fb211594` the last feature bit is `MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_AT` (`:136-142`; - `crates/capi/src/abi.rs:64-71`). Other decision-15 entry points may take bits before this slice - merges. -- The successor entry points take a `SuccessorBase` (`crates/host-core/src/prepare.rs:641-647`). -- D15-8 (round-5 amendment): a pending warm successor is a candidate published `Primed { - not_before, lead_blocks }` (*Adopt a successor plan no earlier than a scheduled sample*, #1311) - whose carried nodes are floored at `a(n) + P`. At a host-declared stop it is superseded by a - plain rebuild through #1310, with no continuity constraint, and its revision completes `exact` - or `superseded`. This slice owns that rule; it absorbs the retired #1359. -- The accumulated read-ahead `ΣP` of the warm successor resets at the same declaration (D15-8). - *Give a plan a source-read clock that leads its render clock* (#1396), a slice of *Grow latency - during playback by adopting a primed warm successor* (#1287), introduces that read-ahead and - resets it at the declaration defined here. - -## Decisions frozen for this slice - -- **D1. The declaration.** - - A new C ABI entry point, `uint32_t miso_engine_v1_declare_discontinuity(miso_engine_v1_session - *session)`, guarded by `MISO_ENGINE_V1_FEATURE_DECLARE_DISCONTINUITY`. Its value is the next - free bit when this slice merges, and the mask grows to match. This spec fixes no bit number; - code, tests and docs name the bit by its symbol (decision 15, D15-12: a feature bit per - addition). - - It is a control-thread call. Its meaning: the next block the host renders need not continue the - previous output, because the host has stopped or is about to seek every source. - - The header documents the duty: a host calls it at a stop, and before a seek of every source. - - The ABI growth rule of *Document the seek contract and the C ABI growth rule in the header* - (#1317) applies: one feature bit, the mask never compared with `==`. -- **D2. What it does.** - - The declaration rebuilds if the newest plan (the pending candidate, or else the active plan) - was compiled with any floor above its node's natural arrival, or reads its sources ahead of - render (a source-read offset above 0). The control plane then prepares the committed model as a - successor at a discontinuity. That successor has no floors and carries only the source - consumers (ring, generation and read position). Every other owner starts at rest: there is no - continuity constraint. It is published for adoption at the next block. - - The offset term matters because a plan can lead with no raised floor: after a warm growth, a - later edit can make a node's natural arrival equal its floor. The offset is recorded in the - inventory by *Give a plan a source-read clock that leads its render clock* (#1396) D2, which - lands after this slice and adds the term to this test. Before #1396 no plan has an offset, so - the floor term alone is the whole test. - - The call lives in `crates/control-plane/src/`; the capi entry point only forwards to it. - - A pending candidate is superseded by *Supersede an unadopted candidate plan by - compare-and-swap* (#1310): withdrawn, used as the donor of the rings it created (#1310 D2), its - persisting producers returned to the running epoch (#1310 D5 step 5), then dropped. - - **A pending warm successor.** A `Primed` candidate always has a raised floor (its carried nodes - are floored at `a(n) + P`, and `P > 0`), so the declaration always rebuilds while one is - pending, even when the active plan has no raised floor. It is superseded as above. The - discontinuity successor is published `Next`, never with the withdrawn candidate's `Primed` - kind: it has no lead, no readiness check, no deadline and no transition. The same holds before - a seek of every source. The control plane's `PrimedCandidate` record of the withdrawn candidate - is dropped with it, and the discontinuity successor leaves none (*Classify a latency-growth - edit and publish its warm successor from the control plane*, #1403 D4, which adds the record - and this drop). - - If neither term holds, the call returns OK and publishes nothing. -- **D3. Host-core.** `SuccessorBase` gains `discontinuity: bool`. When it is true, the successor - is compiled with an empty floor map, and the join carries sources only. Its source-read offset - is 0 (#1396 D2, which lands after this slice and reads this flag). The declaration may or may not - be followed by a seek: a stop without a seek follows #1396 D3. Each carried consumer keeps its - read position, so no frame is repeated or skipped, and the source-read clock steps back to the - render clock at the adoption block. The inventory records - natural arrivals beside floored ones, so D2's test is a comparison of two numbers. - - **The constructor.** `SuccessorBase::new(inventory, committed, revision)` exists from - *Carry fader, mute and pan ramps across a plan swap* (#1277 D10), which added `revision` - first; `revision` has no neutral value, so `new` takes it as an argument. This slice adds - `discontinuity` to `new`'s output: `new` sets every other field to its neutral value, - `discontinuity: false` here. Each later slice that adds a field extends `new` with that - field's neutral value (*Prepare a warm successor whose carried nodes lead the predecessor by - P*, #1354 D4: `warm: None`; *Duck-swap the strips a latency growth restarts, and fall back to - the transition when a warm successor cannot adopt*, #1397 D2: `forced_restart` empty). - - Every literal construction site that exists when this slice lands moves to `new` in this - change: - - `crates/host-core/tests/successor_swap.rs:988` and - `crates/host-core/tests/support/successor.rs:101`; - - `crates/capi/tests/resource_lifecycle.rs:1591`; - - the control plane's sites in `crates/control-plane/src/`: the `prepare_runtime` call - (`crates/control-plane/src/control.rs:1046`, moved there by #1309 D2) and #1310 D2's - base across a withdrawn candidate; - - `crates/host-core/tests/withdrawn_successor.rs`, the gates of *Prepare a successor across a - withdrawn candidate plan* (#1344), which lands before this slice through #1310. - - A site added by a slice that is not ordered against this one is in the list if that slice - lands first. The discontinuity successor is - `SuccessorBase { discontinuity: true, ..SuccessorBase::new(inventory, committed, revision) }`. Every - later site is built through `new`, and a later field changes `new` and no construction site. -- **D4. Acked-batch rule.** Preparation can fail (budgets, ceilings). On failure the call returns - the preparation error and nothing changes. On success the plan is published with its retirement - credit reserved, as a structural transaction is. The model and the revision do not change. No - acknowledged edit is lost: the committed model is the source of the successor. - - **Completion.** The discontinuity successor carries the committed revision, so a withdrawn - candidate's revision completes when that successor is adopted. #1310 D6 stores its - `superseded` word with that same revision as B's: the advance is `EXACT`, with `SUPERSEDED` - when the withdrawn candidate had itself folded in earlier revisions. It is never - `TRANSITION_FALLBACK`: no continuity is owed after a declared stop, so nothing fell back. -- **D5. Both hosts.** The call is implemented in the control plane, in the crate of *Extract the C - ABI control plane into a portable crate both hosts call* (#1309). The C ABI exposes it here. The - browser reaches the same function when *Run the browser control plane in a Worker and keep the - AudioWorklet render-only* (#1332) lands, and its SDK surface belongs to *Apply session - transactions from the browser SDK* (#1296). - -## Deliverables - -1. D1: the entry point in `crates/capi/src/ffi.rs`, the feature bit and mask in - `crates/capi/src/abi.rs`, the header prototype and text, and `docs/C_ABI_V1_QUALIFICATION.md`. -2. D2 and D4 in `crates/control-plane/src/` (package `control-plane`, lib `control_plane`, created - by #1309). -3. D3 in `crates/host-core/src/prepare.rs`, with the move of every literal `SuccessorBase` site - to `SuccessorBase::new`. -4. Tests in `crates/host-core/tests/successor_swap.rs`, `crates/capi/src/runtime/tests.rs` and the - control-plane crate's unit tests (gate 5). - -## Authorized paths - -- `crates/host-core/src/prepare.rs`, `crates/host-core/tests/successor_swap.rs` -- The `SuccessorBase` construction sites only (D3): `crates/host-core/tests/support/successor.rs`, - `crates/host-core/tests/withdrawn_successor.rs` (#1344's) and - `crates/capi/tests/resource_lifecycle.rs` -- `crates/capi/src/ffi.rs`, `crates/capi/src/abi.rs`, `crates/capi/src/runtime/tests.rs`, - `crates/capi/include/miso_engine_v1.h`, `docs/C_ABI_V1_QUALIFICATION.md`, and - `crates/control-plane/src/` (#1309). These are stream B's files; root orders the merge after - #1309 and #1310. -- `scripts/check-capi-abi.sh` (only if its symbol list must learn the new entry point). - -## Non-goals - -- No read-ahead reset: #1396 sets the discontinuity successor's source-read offset to 0 (its D2) - and defines the stop without a seek (its D3). -- No browser export (#1332, #1296). -- No change to the render clock's contiguity rule. - -## Objective gates - -1. **Floors reset, host-core.** A has three tracks, the first with a true-peak limiter insert. B - removes that track and is swapped in after block 6 with A's floors (#1285). Then prepare B again - with `discontinuity: true` and swap it in after block 10. From block 11 on, its output equals a - fresh B, compiled without floors, whose sources were fed the same PCM from the frames the carried - consumers stood at. `latency_samples` is B's natural latency. -2. **Floors reset, C ABI.** The same edit as a `RemoveTrack` transaction, then - `miso_engine_v1_declare_discontinuity`. After one render, `miso_engine_v1_plan_resources` - reports the natural `latency_samples`. Before the declaration it reported A's. -3. **Nothing to reset.** On a session with no raised floor, the declaration returns OK, publishes - no plan (the swap counter is unchanged), and leaves the output bit-identical to a run without - the call. -4. **Failure changes nothing.** With a resource cap set so that the floorless preparation fails, - the call returns the preparation error, and the active plan, its floors and the committed model - are unchanged. -5. **A declared stop supersedes a pending warm successor (new control-plane test, - `test-support`).** Gate 1's A runs with no raised floor. Commit its `RemoveTrack` edit, and - publish the resulting B (floored at A's arrivals by #1285, so above its natural ones) through a - `test-support` publication hook as `Primed { not_before, lead_blocks: 1 }`, with `not_before` - 64 blocks ahead. Render 4 blocks (B stays pending), then declare a discontinuity and render 8 - blocks. - - The block after the declaration is the discontinuity successor's, and from it the output - equals a fresh B compiled without floors, fed the same PCM from the frames the carried - consumers stood at; `latency_samples` is B's natural latency. - - The withdrawal reported `Withdrawn`, and B was never adopted. - - The watermark advances to the committed revision at that block with `EXACT` and without - `TRANSITION_FALLBACK`. Repeated with a structural edit committed and superseded before B (so - B folds in one revision): `EXACT | SUPERSEDED`, `superseded_count` grows by 1. - - Owners and retirement credits balance, and no producer was dropped on the control thread. -6. **ABI surface.** `query_capabilities` reports `MISO_ENGINE_V1_FEATURE_DECLARE_DISCONTINUITY`, - and the mask test checks that the mask is the OR of every feature symbol, the new one - included (by symbol, never by a literal number). -7. Commands: - - `cargo test --locked -p host-core -p control-plane -p capi --features - host-core/test-support,control-plane/test-support` - - `bash scripts/check-capi-abi.sh` and `bash scripts/check-capi-abi.sh --self-test` - - `cargo build --locked --release -p audit && ./target/release/audit capi` - - `bash scripts/check-workspace-policy.sh`, `cargo fmt --all -- --check`, - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --no-deps` - -## Test value - -- Gate 1: a reset that keeps the floors, or one that carries DSP state, diverges from the fresh - floorless reference. It turns red. -- Gate 2: a declaration that clears floors in the inventory but never publishes a plan keeps - reporting A's latency. It turns red. -- Gate 3: a declaration that always rebuilds resets every effect tail on every stop. It turns red. -- Gate 4: a declaration that drops floors before its preparation succeeds leaves a plan whose next - successor loses latency mid-playback. It turns red. -- Gate 5: a declaration that publishes its successor with the pending candidate's `Primed` kind, - or leaves a warm candidate to its own adoption, plays the floored plan past the stop (the - successor waits for `not_before` and readiness), so the floors survive it; one that reports the - revision as `TRANSITION_FALLBACK` miscounts a stop as a fallback. Gates 1-4 have no `Primed` - candidate. It turns red. - -## Dependencies - -- *Keep every node's latency from dropping during playback* (#1285). -- *Extract the C ABI control plane into a portable crate both hosts call* (#1309). -- *Supersede an unadopted candidate plan by compare-and-swap* (#1310). -- *Adopt a successor plan no earlier than a scheduled sample* (#1311): the `Primed` kind gate 5 - publishes. -- *Publish an applied-revision watermark and complete edits asynchronously* (#1314): gate 5's - outcome flags. - -## Amendment (root, 2026-10-11): `SuccessorBase::new` takes `revision` - -Root amended this spec per the *Carry fader, mute and pan ramps across a plan swap* (#1277) ruling. -#1277 D10 adds `SuccessorBase::revision`, which has no neutral value. So D3's constructor is -`SuccessorBase::new(inventory, committed, revision)`, and the sentence "the first slice that adds a -field" is removed. Nothing else changes. - -### Open question for root (from the #1277 ruling review, 2026-10-11) - -The discontinuity successor publishes the *committed* revision on a fresh gate, and a session at -revision 0 would be refused by `stamp(0)` (and by host-core's entry check, `host.successor.revision`, -which requires `1 <= revision`). Root decides how this slice handles a session at revision 0 when -#1323 is next. - -## Amendment (controls coordinator, batch 15 check, 2026-10-15) - -Checked against `228fca26c`. - -**Moved anchors.** -- "render.time.discontinuity" is a render diagnostic, now at - `crates/capi/include/miso_engine_v1.h:182`. The seek prototypes are `:420-430`. -- The last feature bit is now `MISO_ENGINE_V1_FEATURE_SERVICE` (`1 << 7`, - `crates/capi/src/abi.rs:70`, header `:251`). The mask is `abi.rs:72-79` and header `:252`. #1316 - and #1642 also add bits. D1's "next free bit when this slice merges" still governs. -- The mask test at `crates/capi/src/abi.rs:554` asserts `FEATURE_MASK == 0xff`, a literal. Gate 6 - replaces that assertion in this slice. -- `SuccessorBase` is `crates/host-core/src/prepare.rs:1070-1097`. -- The control plane's `prepare_runtime` call is `crates/control-plane/src/control.rs:1662`, with - its `SuccessorBase` literal at `:1670`. - -**`SuccessorBase::new` does not exist.** #1277 closed without it, so this slice adds it. -- `SuccessorBase` now has six fields: `inventory`, `committed`, `revision`, `edit_ramps` (#1394), - `monitor` (#1559) and `observation` (#1584). -- Each new field has a neutral value: `EditRamps::new()` is `const` - (`crates/session/src/edit_ramp.rs:102`), `MonitorBase::NONE` (`prepare.rs:1125`) and - `NO_OBSERVATION` (`prepare.rs:1100`). `new` sets those and `discontinuity: false`. - `edit_ramps` is a reference, so `new` needs a static neutral `EditRamps` beside - `NO_OBSERVATION`. - -**Construction sites at `228fca26c`.** These replace D3's list: -- `crates/control-plane/src/control.rs:1670`; -- `crates/host-core/tests/support/successor.rs:98`; -- `crates/host-core/tests/successor_swap.rs:1043`, `:2663`, `:3926`, `:3950`, `:3980`, `:7159`, - `:9331`; -- `crates/host-core/tests/browser_successor.rs:284`; -- `crates/host-core/tests/effect_observation.rs:544`; -- `crates/capi/tests/resource_lifecycle.rs:1805`; -- `hosts/host-web/src/tests.rs:5938`. - -`crates/host-core/tests/withdrawn_successor.rs` does not exist (#1344 is open). Add to the -authorized paths, for the construction sites only: `crates/host-core/tests/browser_successor.rs`, -`crates/host-core/tests/effect_observation.rs` and `hosts/host-web/src/tests.rs`. - -**Dependencies.** -- #1285, #1317 and #1396 are in the batch. -- #1309 is closed: the control plane is `crates/control-plane/src/`. -- #1311 is closed: `PlanAdoption::Primed` is `crates/engine/src/realtime/spsc.rs:532`. -- #1314 is closed: the outcome flags are header `:255-256`. -- #1310 is open and not in batch 15/16. D2's supersede, D4's completion and gate 5 need it. The - engine's `withdraw` exists (`crates/engine/src/realtime/plan_exchange.rs:552`, #1343), but #1310's - compare-and-swap and its D2, D5 and D6 do not. #1310 also waits on #1344 and #1398, both open. -- #1403 (open) adds `PrimedCandidate` later. It is not a dependency. -- Batch order (root, 2026-10-15): batch 15a is the controls lane, #1344, #1398 and #1310. - #1324 left batch 15a (root ruling, 2026-10-15); it lands in a later batch after #1288, #1391, - #1363 and #1325. - The rest of batch 15 follows. This slice is in the rest (B15-R5). - -**B15-R5, root decision under owner delegation (2026-10-15): order #1310, #1344 and #1398 before #1323. No placeholder -refusal.** #1344, #1398 and #1310 are in batch 15a; #1323 follows in the rest of batch 15. -The finding follows. D2 supersedes a pending candidate -through #1310, which is not planned for this batch. A declaration while a candidate is pending has -no rule without it. Proposed: order #1310, #1344 and #1398 before #1323. Do not ship a placeholder, -such as a typed backpressure refusal while a candidate is pending. - -**B15-R6, root decision under owner delegation (2026-10-15): no revision-0 special case. At revision 0 the call returns OK and -publishes nothing.** D2 adds that sentence. Gate 3 adds a revision-0 case: a declaration -on a host at revision 0 returns OK, publishes no candidate, and leaves the next render and the -reported latency unchanged. The finding follows. Proposed answer: no special -case. Floors and source-read offsets come only from a successor. A successor needs -`1 <= revision` (`host.successor.revision`, `prepare.rs:1915`, `:3200`), and the committed revision -only grows. So when D2's test holds, the committed revision is at least 1. At revision 0 the call -returns OK and publishes nothing. Add that sentence to D2 and a revision-0 case to gate 3. - -**B15-R7, root decision under owner delegation (2026-10-15): this slice also adds `miso_engine_web_v1_declare_discontinuity(handle) --> u32`.** It forwards to the same control-plane function. It adds the export-list mirrors -of #1293 D7 and a native host-web gate equal to gate 2 (in `hosts/host-web/src/tests.rs`). -#1643 calls it before its seek. Add `hosts/host-web/src/ffi.rs`, `hosts/host-web/src/lib.rs` and -the mirror files of #1293 D7 to Authorized paths. The finding follows. D5 leaves the browser to #1332 and -#1296. Since #1381a the browser runs `control_plane::SessionState` in its one instance -(`hosts/host-web/src/lib.rs:6-16`), so the function is reachable there now. Only a browser entry -is missing. Decision 17 C1 says a behaviour on one host only is a defect. #1642 makes a C ABI host -declare before a session seek, but #1643 (the browser session seek) does not, so browser floors -would never reset. Proposed: this slice also adds a control-half export -`miso_engine_web_v1_declare_discontinuity(handle) -> u32` that forwards to the same control-plane -function. It adds the export-list mirrors of #1293 D7 and a host-web native gate equal to gate 2. -#1643 then calls it before its seek. Alternative: carry the declaration as a protocol frame -through `submit_command` and `control_command`, C13's form with no new export. But D1, #1317 and -#1642 already name the C export. - -**Other notes.** -- `TRANSPORT_SET` now carries the state only (#1635, code landed; issue still open). A transport - stop is not a declaration. The host calls D1 itself. -- #1641 (open) gives every plan a timeline that "carries like a source". D2's successor "carries - only the source consumers". If #1641 lands first, the timeline consumer carries too, or the - playhead and stored automation (#1647) restart at the declaration. Whichever lands second adds - it to the discontinuity join. -- Decisions D1-D4 otherwise hold against the code and decision 17. The declaration is not a - control. - -**Gates and commands.** -- The named files exist, except `withdrawn_successor.rs` (#1344, new there). -- `tools/audit/src/capi.rs` exists, and `scripts/check-capi-abi.sh` has `--self-test`. -- Gate 7's commands are valid. Its features exist. - -## Amendment (controls coordinator, batch 15a fold, 2026-10-16) - -- **Batch placement.** "#1310 is open and not in batch 15/16" (Dependencies) is void. #1310, #1344 - and #1398 are batch 15a, which lands before this slice (B15-R5). - -## Decision record: #1323 (batch 15b) - -**What shipped.** -- D1: `miso_engine_v1_declare_discontinuity(session)` in `crates/capi/src/ffi.rs`, a forwarder to - `control_plane::SessionState::declare_discontinuity`. `FEATURE_DECLARE_DISCONTINUITY = 1 << 8` - (the next free bit), and the mask ORs it. Header: the define, the prototype, the session thread - list, and a paragraph "Declared discontinuity (issue 1323)" with the host's duty. The #1285 - paragraph now points to it. `scripts/check-capi-abi.sh` lists the symbol (18 frozen). -- B15-R7: `miso_engine_web_v1_declare_discontinuity(handle) -> u32` in `hosts/host-web/src/ffi.rs`, - through `AudioWorkletEngineHost::declare_discontinuity`, which runs the same control-plane - function. The host's `run_command` became `run_control`, so the plan a declaration publishes - keeps its tables as a rebuild's do. The #1293 D7 mirrors are updated and the SDK is regenerated. -- D2/D4 in `crates/control-plane/src/control.rs`. `rebuild` takes `Rebuild::Transaction(token)` - or `Rebuild::Discontinuity(Box)`. The discontinuity arm has no protocol commit - and returns no response. Its copy of the committed model is the kept base of the epoch it - displaces (the direct case), or it is dropped when the arm supersedes a candidate, as a commit's - displaced model is. It is always published `Next`, and the test adoption hook is not taken. - Supersession, the donation, the acked-PCM guard, observation forwarding, the `superseded` word - (`r - 1 - last_running`) and republish-on-refusal are the transaction path's own code. -- D3 in `crates/host-core/src/prepare.rs`. `SuccessorBase::discontinuity`, `SuccessorBase::new` - (with a private static neutral `EditRamps`), and every literal site moved to `new`. At - preparation, a discontinuity base swaps its inventory for `at_discontinuity()`: identity, rate, - quantum and source rows only, with no floors. Every join reads the base's inventory, so that one - substitution is the whole rule. The inventory records `raised_arrivals` (each raised node's - natural arrival, beside its floored one in `input_floors`), and - `runs_behind_natural_timing()` is D2's test. - -**Rule for the supersession case (the spec is silent).** A transaction E that supersedes a -discontinuity successor D before render adopts it is prepared at the same discontinuity (sticky -`ProviderEpoch::discontinuity`). The combined twin P0 -> E is "declare, then edit" as one -transaction against the running plan, and render never played D. So E has no floors and carries -only sources. A declaration while D waits finds nothing to reset (D has no raised floor) and -publishes nothing. D over a withdrawn A carries A's revision. A's revisions complete at D's -adoption, `EXACT`, with `SUPERSEDED` for those A folded in. - -**Departures.** -- `crates/graph-compiler/src/{pdc.rs,compile.rs,lib.rs}` (not authorized): D3 needs natural - arrivals in the inventory, and only PDC knows them. `GraphCompileReport::raised_arrivals` is a - report field only. Program, canonical bytes and fixtures are unchanged, and the unit test is - extended. -- Gate 5 and the supersession gate are in `crates/capi/src/runtime/tests.rs`, not in control-plane - unit tests. Those tests drive `control_plane::SessionState` directly. The control-plane crate has - no render or session-fixture harness, and its `tests/` directory is not authorized. -- Gate 4's error is the replacement-peak cap's `CompileRejected` (set with - `set_replacement_peak_limit`). The C ABI's limits give no per-preparation cap that fails only - the floorless successor. -- Mechanical edits outside the paths: `crates/capi/tests/c/{abi_smoke.c,header_smoke.cpp}` (the - bit inside the mask and the signature, replacing `MASK == 255`), `crates/host-core/tests/strip_meters.rs` - (a `SuccessorBase` site added after the batch check), `ProviderEpoch::test_inventory`, and the capi - test wrappers `test_declare_discontinuity` and `test_plan_latency`. -- `hosts/host-web/tests/control_parity.rs`: a parity row, at the coordinator's request. -- Header: the #1310 paragraph is re-wrapped to 100 columns (one line was 135). -- No browser latency report exists yet (#1688). So the browser gate reads the plan latency through - the host's `queries`, and the parity row compares bits and the C ABI latency. - -**Gates.** -- Gate 1: `host-core` `successor_swap::a_declared_discontinuity_resets_the_floors_at_every_rate_and_width`. - Red when the floors are kept (447 against 0 at 44.1 kHz) and when all state is carried (block 0 - differs). -- Gate 2: `capi` `runtime::tests::a_declared_discontinuity_reports_the_natural_latency_on_the_c_abi`. - Red when nothing is published. Browser: - `host-web` `tests::structural_apply::a_declared_discontinuity_resets_the_floors_on_the_browser`, - red when the host does not forward. Parity: - `control_parity::a_declared_discontinuity_resets_the_floors_alike_on_both_hosts` (48 and - 96 kHz), red at block 8 when the browser does not forward. -- Gate 3: `...::a_declaration_with_nothing_to_reset_publishes_nothing` (revision 0 and after an - added track). Red when the declaration always rebuilds. -- Gate 4: `...::a_refused_declaration_changes_nothing`. Red when the error is swallowed. The retry - under the lifted cap resets the floors. -- Gate 5: `...::a_declared_stop_supersedes_a_pending_warm_successor_alone_and_folded`. Red when - the successor is published `Primed`, and when the pending candidate is left alone. -- Supersession: `...::an_edit_that_supersedes_a_discontinuity_successor_is_prepared_at_it`. Red - without the sticky rule. -- Gate 6: `abi::tests::masks_and_result_codes_are_frozen` (the OR of the symbols) and - `ffi::tests::version_and_capabilities_are_exact`. -- Gate 7 command: all suites pass. `host-web` (test-support) and `graph-compiler` suites pass. - `check-capi-abi.sh` and `--self-test` pass. `check-workspace-policy.sh` passes. `fmt` passes. - Clippy passes on the touched crates. Rustdoc passes. `realtime-policy` passes. - `check-sdk-generated.sh` passes. `check-abi-layout-v1.py` and `--self-test` pass. - `check-web-audioworklet.sh` passes. - -**Measured.** `audit capi`: allocations 0, deallocations 0, syscalls 0, pcm_digest -`4f52089e4a8cf83c` (unchanged; `tools/audit` not touched). C ABI gate 2: latency 486 -> 0 at -48 kHz. No resource ceiling was re-pinned. - -## Amendment (root ruling B16-R9, batch 16b review, 2026-10-16) - -B15-R7's order ("#1643 calls it before its seek") is retired. A plain session seek (#1642, -`SessionState::session_seek`) is a declared discontinuity: it runs this slice's declaration in the -same call, through the same core path (`declare_discontinuity_serviced`), after every check and -before any push; a refused seek declares nothing, and a refused declaration pushes nothing and -returns its own error. An anchored session seek declares nothing (it keeps continuity: D2 publishes -`Next`, before the anchor). No host and no SDK calls `declare_discontinuity` before a session seek. -D1's duty is now: a host calls it at a stop, and before a seek of every source made with -per-source seeks. B15-R7's browser export stays as it is. diff --git a/.github/ISSUE_SPECS/1344-prepare-a-successor-across-a-withdrawn-candidate-plan.md b/.github/ISSUE_SPECS/1344-prepare-a-successor-across-a-withdrawn-candidate-plan.md deleted file mode 100644 index b68a33e89..000000000 --- a/.github/ISSUE_SPECS/1344-prepare-a-successor-across-a-withdrawn-candidate-plan.md +++ /dev/null @@ -1,416 +0,0 @@ -# Prepare a successor across a withdrawn candidate plan - -Stream B of decision 15 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`, D15-9, D15-17). -Code anchors verified on `main` at `6fb211594`. - -## Product outcome - -Host-core can prepare a successor plan B for the plan render runs (P0) when an earlier candidate A -was prepared, committed and then taken back before render adopted it. B carries from P0 exactly -what A would have carried and B leaves unchanged, and B takes over the sources A created together -with the PCM, generation, read position and held seek the host already gave them. The donation is -checked completely against A before anything is committed, and then cannot fail. This gives supersession (#1310) the functions -it needs; on its own it changes no host behaviour. - -## Context - -- A successor is prepared against one predecessor: `SuccessorBase { inventory, committed }` - (`crates/host-core/src/prepare.rs:641`). Its carry program names that predecessor's identity - (`GraphCarryProgram { predecessor: base.inventory.plan_identity(), .. }`, `prepare.rs:1294-1302`, - installed at `:1792`). Carried sources and input sections are chosen against the inventory rows - and the committed model (`carried_inputs`, `:1263-1291`); the input moves are installed with - `graph::install_builtin_input_carry` (`:1820`). -- The installed hand-over lives in the graph executor: `carry: Option` (source - moves, `(successor index, predecessor index)` pairs) and `carry_inputs: Box<[GraphLaneMove]>` - (predecessor lane locations), `crates/graph/src/lib.rs:3008-3009`; installers at `:2837` and - `:2876`. -- `PreparedHost` (`prepare.rs:525-538`) keeps the plan's own `inventory`, not which predecessor - rows it took. Inventory rows carry the predecessor keys the hand-over reads: a source row's - `index` (`:542-550`) and an input-section row's `location` (`:566-577`). -- A source the successor carries is prepared vacant; render moves the consumer in with - `adopt_sources`, which requires a vacant successor entry and an occupied predecessor entry - (`crates/source/src/lib.rs:1846-1885`; trait `GraphPreparedSourceSetDriver::adopt_sources`, - `crates/graph/src/lib.rs:2225`). Producers move by ID with `SourceControlSet::adopt_persisting` - (`crates/host-core/src/source.rs:286`). -- The held seek and the generation live in the consumer (`PcmSourceConsumer::held_seek`, - `crates/source/src/lib.rs:1018`). -- *Adopt a successor plan no earlier than a scheduled sample* (#1311): render adopts a candidate in - the same step as it claims it, so a withdrawn candidate was never rendered and is wholly - control-owned. Under D15-8 (round-5 amendment) this holds for a warm successor too: render - checks its readiness on the running plan's consumers and primes it only at adoption. - -## Decisions frozen for this slice - -- **D1. Derive what a successor carried from its installed hand-over.** `PreparedHost` gains - `carried_base: Option`. It is computed once, at the end of preparation, from - what is actually installed in the plan, never recorded family by family: - - `graph::installed_carry_reads(&mut PreparedRenderPlan) -> Option` lists every - predecessor key the installed hand-over reads: source indices from the program, lane locations - from the input moves. It destructures the executor's carry fields without `..`, so a carry - section added to the executor later does not compile until this function reports it. That makes - the installer of each new family its owner here. - - Host-core restricts the predecessor's inventory to the rows whose key appears in those reads. - It keeps the predecessor's plan identity. The restriction destructures `PlanStateInventory` - without `..`, for the same reason. - - `None` for a plan prepared without a successor base. -- **D2. Prepare across the withdrawn plan with the existing API.** The caller passes - `SuccessorBase { inventory: A.carried_base, committed: P0's committed model }`. Every row of the - restricted inventory is a P0 row, so the existing joins run unchanged: an owner carries from P0 - only if A carried it and B leaves it unchanged against P0's model; everything A restarted, added - or removed is fresh in B. No new preparation entry point. -- **D3. Donation in two steps: check, then apply.** - - `check_donation(successor, donor) -> Result` is read-only. The - successor side is a prepared plan, its `SourceControlSet`, its inventory and its committed - model. The donor side is a withdrawn plan the control thread holds, with its - `SourceControlSet`. - - It selects every source that is fresh in the successor and created fresh in the donor, with the - same ID, the same declaration (content, channels, bit depth, frames) and the same ring - configuration. It then checks every condition the apply step needs (D4, D5) and returns the - pairs. It moves nothing. - - The apply has two infallible halves, each consuming its part of the `DonationPlan`: - - `donate_producers` swaps the two producers in the control sets; - - `donate_consumers` swaps the two consumers inside the plans (D4). - `apply_donation(successor, donor, DonationPlan) -> usize` runs both, in that order, and - returns how many moved. The donor ends with the successor's fresh rings and drops them with - itself. - - Between check and apply neither side can change what was checked: the successor is - unpublished and control-owned, and so is the withdrawn donor. `DonationPlan` borrows neither - side. -- **D4. The consumer swap.** `GraphPreparedSourceSetDriver` gains `can_swap_sources(&self, other, - pairs) -> bool` and `swap_sources(&mut self, other, pairs)`, the occupied-to-occupied twin of - `adopt_sources`. The check verifies every pair (both occupied, equal channel counts and quanta). - The swap is infallible on checked pairs and moves each entry's whole contents. `graph` exposes - `can_donate_sources` and `donate_sources(successor: &mut PreparedRenderPlan, donor: &mut - PreparedRenderPlan, pairs)` through the executors, as - `install_carry_program` reaches them. It runs on the control thread, on two plans neither of - which render owns. -- **D5. Preconditions, checked in `check_donation`.** The successor is unpublished, and every - donated ring is unconsumed since its generation start: its consumer has never begun a block. - - `PcmSourceConsumer::has_begun_block(&self) -> bool` is a new read-only accessor over a - render-owned `bool` that `begin_block_with` (`crates/source/src/lib.rs:1119`, called by - `begin_block` at `:1103` and `begin_block_at` at `:1115`) sets. Before that first block the - consumer has popped no PCM and no command, so every acked chunk, the generation and any seek - are still in the ring. - - A withdrawn donor was never rendered by render (#1311), so in production the condition always - holds. It is still checked, because a plan rendered on the control thread (a synchronous host, - or a test) can be a donor. A donor that played one of its fresh rings is refused with - `DonationError::Consumed`, a typed error. - - An adopted plan is never a donor: it is the base (#1310's *taken* case). -- **D6. Acked-batch question: can an ack ever precede a drop? No.** This slice acks nothing. It - exists so that #1310 and the warm successor's transition fallback (#1358, #1397) never drop PCM, - a generation or a held seek the host was acked for: they stay in a ring no consumer has begun, - and they move with the consumer. Every refusal happens in `check_donation`, so a caller can run - it before the protocol commit, and the post-commit halves cannot fail. - -## Deliverables - -1. D1 in `prepare.rs` and `graph`; D3 (with its two halves) in host-core; D4 - and D5's accessor in `graph` and `source`. -2. Doc comments stating D2's base rule and D3's check-then-apply contract. - -## Authorized paths - -- `crates/host-core/src/prepare.rs` (D1, and the plumbing for D3): stream A's file, sequenced by - the coordinator. -- `crates/host-core/src/source.rs`, a new `crates/host-core/tests/withdrawn_successor.rs`. -- `crates/graph/src/lib.rs` (`installed_carry_reads`, the D4 trait methods and - `can_donate_sources`/`donate_sources` only): stream A's crate. -- `crates/source/src/lib.rs` (the `can_swap_sources`/`swap_sources` implementation and the - `has_begun_block` accessor only). - -## Non-goals - -- Withdrawal (#1343) and the control-plane wiring (#1310). -- Any render-thread change. - -## Objective gates - -1. **Base across a withdrawn plan (new test, `withdrawn_successor.rs`).** Prepare P0 with sources - `a`, `b` and tracks `t1`-`t3`. Prepare A against P0: change `t1`'s insert quality, remove `b`, - add source `c`. Prepare B with D2's base: change `t2`'s insert quality. Twin: render-free - `A.adopt_predecessor_plan(&mut P0')`, then B' prepared against A normally and - `B'.adopt_predecessor_plan(&mut A)`. B adopting P0 renders bit-identically to B' over 16 blocks, - with `a` carried, `t1` and `t2` restarted, and `b` absent. -2. **`carried_base` follows the installed hand-over (same file).** For A above, `carried_base` - holds exactly `a`'s source row and one input-section row per input move A installed, each - equal to P0's row for that strip, with P0's identity. A plan prepared with no base has `None`. -3. **A source absent from the running plan is never carried from the withdrawn one (same - file).** P0 lacks `d`. A adds `d` with 4 seconds of frames; B adds `d` with 5 seconds, so no - donation applies: B allocates a fresh ring for `d` (generation 1, frame 0), not A's. (A source - present in P0 that A removes and B restores is *Remove a strip in two phases: ramp out, then a - scheduled swap* (#1325) D6's restore, which keeps the running plan's ring; this gate does not - cover it.) -4. **Donation (same file).** Feed `c` through A's producers for 4 blocks and `seek_at(c, 2, 0, - S)`; prepare B; `check_donation` returns one pair and `apply_donation` returns 1; B adopting P0 - plays `c` from `S` with the fed PCM, as the twin does. -5. **Refusals move nothing (same file).** A donor whose fresh `c` consumer began one block (a - control-thread `render_contiguous` of A) is refused with `DonationError::Consumed`, and so is - a declaration or ring-config mismatch. Both control sets and both plans' source reports are - unchanged after each refusal. -6. **Realtime and workspace.** `cargo test --locked -p host-core --features - control-provider,test-support`; `cargo test --locked -p graph --features test-support`; - `cargo test --locked -p source`; `cargo run --locked --release -q -p realtime-policy`; - `bash scripts/check-graph-policy.sh`; `bash scripts/check-host-core-policy.sh`; - `cargo fmt --all -- --check`; `cargo clippy --locked --workspace --all-targets --all-features - -- -D warnings`. - -## Test value - -- Gate 1: a base that carries what A restarted, or compares against A's model instead of P0's. -- Gate 2: a `carried_base` that lists rows the hand-over does not read, or misses one it does. -- Gate 3: a carry rule that takes a ring from the withdrawn plan's inventory (instead of by a - checked donation) for a source the running plan does not have. -- Gate 4: a donation that drops acked PCM or a held seek. -- Gate 5: a check that passes a consumed ring (whose dropped frames the host was acked for), a - donation the apply step cannot perform, or a check with a side effect. - -## Dependencies - -- *Carry fader, mute and pan ramps across a plan swap* (#1277): stream A's open edit of - `prepare.rs` lands first. Its new carry section is then reported by `installed_carry_reads` (D1). - -## Amendment (controls coordinator, batch 15 check, 2026-10-15) - -Checked against `e2f6cfb24`. - -**Batch order (root, 2026-10-15).** Batch 15a is the controls lane: #1344, #1398, #1310, in -that order. #1324 left batch 15a (root ruling, 2026-10-15); it lands in a later batch after #1288, -#1391, #1363 and #1325, in that order. The rest of batch 15 follows. This slice is first. - -**Moved anchors.** Each fact in Context still holds, except as noted. -- `SuccessorBase` is `crates/host-core/src/prepare.rs:1070-1097`. It now has six fields: - `inventory`, `committed`, `revision` (#1277 D10), `edit_ramps` (#1394), `monitor` (#1559) and - `observation` (#1584). D2's literal `SuccessorBase { inventory, committed }` must set all six. - Tests here use the neutral values: `EditRamps::new()`, `MonitorBase::NONE` (`prepare.rs:1125`), - `NO_OBSERVATION` (`:1100`) and a revision of at least 1 (`host.successor.revision`). The real - values for a superseding successor are #1310's. -- The carry program is built at `prepare.rs:2131-2134` and installed at `:2744`. A second - install, with no sources, is at `:3067-3073` (a plan whose only carry is a line or a send). -- `carried_inputs` is no longer a function. It is a local at `prepare.rs:2016`; the input moves - are installed at `:2772`. `carried_sources` is the local at `:1953`. -- `PreparedHost` is `prepare.rs:659-675`. `SourceInventoryRow` is `:695-703` (`index` at `:702`). - `InputSectionInventoryRow` is `:871` (its `location` at `:876`). `PlanStateInventory` is - `:711-751`. -- The executor's carry fields are `crates/graph/src/lib.rs:3211-3244`. The layout witness - `GraphExecutorWithoutSplitPairTable` repeats them at `:4445-4456`. The installers are - `install_builtin_input_carry` `:3390` and `install_carry_program` `:3662`. -- `GraphPreparedSourceSetDriver` is `graph/src/lib.rs:2565`; its `adopt_sources` is `:2629`. -- In `crates/source/src/lib.rs`: `adopt_sources` is `:1902-1938`, `held_seek` `:1018`, - `begin_block` `:1103`, `begin_block_at` `:1115`, `begin_block_with` `:1119`. `begin_block_with` - now takes a private `SeekClock` (`:1030-1038`). -- `PreparedRenderPlan::adopt_predecessor_plan` (gate 1's twin) is - `crates/engine/src/realtime/plan.rs:1201`. -- `SourceControlSet::adopt_persisting` is still `crates/host-core/src/source.rs:286`. - -**D1: the carry families at HEAD.** The executor now installs nine carry sections, not two: -`carry` (sources), `carry_inputs`, `carry_strips` (#1277), `carry_effects` with `carry_scratch` -(#1279-#1282), `observer_carry` (meters and observation taps, #1327), `carry_delays` (#1283), -`carry_strip_delays` and `carry_routes` (#1284). Host-core also carries spectrum captures -(#1395): the pairing is `PlanStateInventory::spectrum_pairing`, built in host-core, not in the -executor. D1 holds as a design. Its scope follows from it: -- `installed_carry_reads` reports a predecessor key for every executor section above. -- The restriction keeps the inventory rows each section reads: `sources`, `input_sections`, - `strip_stages`, `effect_lanes`, `meters`, `observations`, `delay_lines`, `strip_delays` and - `live_routes`. It keeps the `spectrum` rows that A's own `spectrum_pairing` names. -- The scalar fields keep P0's values (`plan_identity`, `sample_rate_hz`, `quantum_frames`, - `spectrum_collection`). `spectrum_pairing` and `restarted` describe A against P0, not P0, so - the restricted base holds them empty. -- Gate 2 covers each family above, one row each, not only sources and input moves. Its test - value covers the same defect for every family. -- The restart set still works across a withdrawn plan. A strip A restarted has no row in the - base, but the joins come from the committed models. `resolve_strip_carry` (`prepare.rs:4799`) - and `resolve_effect_carry` (`:5197`) mark a strip restarted when its predecessor row is - missing. So B lists it in `restarted_strips()` and #1324 ducks it. -- Size: nine families make this slice larger than its brief assumed. The rows are mechanical; - the attempt budget stands. - -**D2-D6 hold** against the code. Decision 17 does not touch them: the base and donation are -host-core internals, with no host surface. -- D3's donor is an `UnadoptedCandidate` (#1343, `crates/engine/src/realtime/plan_exchange.rs:251`). - It gives `plan()` (shared) and `into_plan()`. `check_donation` needs only `plan()`. - `apply_donation` needs `&mut` on the donor; #1310 calls it after the commit, when A is never - republished, so `into_plan()` serves. No `plan_exchange.rs` edit is needed. -- D5's flag is one render-side store in `begin_block_with`. The non-goal "Any render-thread - change" means no change beyond that store. `realtime-policy` covers it. - -**Dependencies on GitHub.** -- #1277 is closed, and so are #1279-#1284, #1311 and #1343. -- #1325 (open) is named in gate 3 for scope only. It is not a dependency. -- Downstream: #1310 (batch 15a), #1358 and #1397 (open, not in batch 15). - -**Authorized paths.** Unchanged. The spectrum rows are read in `prepare.rs`, so -`crates/host-core/src/spectrum.rs` is not needed. - -**Gates and commands.** Every named package, feature and script exists: host-core features -`control-provider` and `test-support`, graph `test-support`, `scripts/check-graph-policy.sh`, -`scripts/check-host-core-policy.sh`, `tools/realtime-policy`. `withdrawn_successor.rs` is new here. - -**Realtime-path edits.** None under `crates/engine/src/realtime`. D5 adds one `bool` store to the -source consumer's block entry (`crates/source/src/lib.rs`, render-side). D4's swap runs on the -control thread. - -## Decision record: #1344 (batch 15a) - -**Shipped.** -- D1: `graph::installed_carry_reads(&PreparedRenderPlan) -> Option` names every - executor field without `..` and reports a predecessor key for all nine carry sections (sources, - input lanes, `(owner, lane)` strip stages, effect sites, `(kind, location)` observers for meters - and spectrum, observation tap sites, delay lines, strip delay lines, live routes; joins and the - scratch read none). `PreparedHost::carried_base` is the base inventory restricted by - `PlanStateInventory::restricted_to` (destructures without `..`), keeping P0's identity, rate, - quantum and capture kind, with `spectrum_pairing` and `restarted` empty. New report row - `carried_base_retained_bytes`; new accessor `PlanStateInventory::spectrum_count`. -- D2: doc on `SuccessorBase`; no new entry point. -- D3: `check_donation(DonationSide, DonationSide) -> Result` - (read-only), `donate_producers`, `donate_consumers`, `apply_donation(DonationSideMut, - DonationSideMut, DonationPlan) -> usize` in `host-core/src/source.rs`. `DonationPlan { producers, - consumers }` holds the two halves; it borrows neither side. -- D4: `GraphPreparedSourceSetDriver::{can_swap_sources, swap_sources}` (source crate implements - them, whole-entry swap, all or nothing); `graph::{can_donate_sources, donate_sources}`. -- D5: `PcmSourceConsumer::has_begun_block`, one `bool` store in `begin_block_with`. - -**Departures.** -- The amendment's "check_donation needs only `plan()`" and "no edit under - `crates/engine/src/realtime`" cannot both hold: no shared executor downcast existed. Added - `PreparedPlanExecutor::as_any(&self)` (default `None`) and `PreparedRenderPlan::executor_any(&self)` - in `crates/engine/src/realtime/plan.rs` (control-side, no render code; one attempt, accepted). -- The driver trait also gained `as_any(&self)` and `source_has_begun_block`, needed to downcast the - donor through a shared reference and to type `DonationError::Consumed`. -- `installed_carry_reads` takes `&PreparedRenderPlan`, not `&mut`. -- Spectrum rows are kept by the observer section's installed `Spectrum` moves (D1's rule), with a - debug assertion that they equal the predecessor entries of A's `spectrum_pairing` (the - amendment's wording). -- A successor with no section installed gets `Some` (P0's identity, no rows), not `None`: its - replacement still needs a base, and nothing carries. -- Gate 5's "declaration or ring-config mismatch is refused": D3 selects only sources with equal - declaration and ring, so a mismatched `c` is not selected; the check returns an empty plan and - nothing moves. A typed refusal would block every other donation and refuse a legitimate - transaction that redeclares a source; the redeclared source's old PCM is not owed, exactly as in - the existing carry rule (#1272 gate 3). `DonationError` is `Consumed` and `Unswappable`. -- Gate 1's "insert quality" edit does not prepare (no launch effect declares a second quality: - `effect.quality.unsupported`); the tests replace the insert's effect (compressor to - gate-expander) under the same insert ID. Gate 1/3/4/5 sessions have no console slot, because on - an AVX2 target a console slot at `Backend::Simd4` fails `console.slot.unbanked` even for the - unmodified fixture (see successor work). - -**Gates** (`crates/host-core/tests/withdrawn_successor.rs`, all pass): -1. `a_successor_prepared_across_a_withdrawn_plan_renders_as_its_twin` (Simd8 and Simd4): 16 - blocks bit-identical to the twin, `a` carried (B's reads name P0's index), `b` absent, - `restarted_strips() == [t1, t2]`; a fresh B differs. -2. `the_carried_base_holds_exactly_the_rows_the_hand_over_reads`: all ten inventory families - non-empty in A's reads, base count equals read count per family, P0 identity, source rows equal - P0's, effect lanes and observations strictly fewer than P0's; a first plan has `None`. -3. `a_source_absent_from_the_running_plan_is_never_carried_from_the_withdrawn_one`. -4. `a_donated_ring_plays_its_acked_pcm_and_held_seek` (Simd8 and Simd4): one pair, apply returns 1, - bit-identical to the twin; without the donation it differs; every rendered block makes zero - allocator calls (`bench_support::alloc` thread counters). -5. `a_refused_or_mismatched_donation_moves_nothing`. -6. `cargo test --locked -p host-core --all-features` (41 targets ok), `-p graph --features - test-support`, `-p source`, `-p host-web --all-features`: pass. `-p capi`: passes with the - mechanical one-line fix below applied (the coordinator later committed it; see below). `audit capi`: `pcm_digest - bf7dcedd1d92621f`, 0 violations. `realtime-policy`: ok, 150 regions (none added). - `check-graph-policy.sh`, `check-host-core-policy.sh`, `check-workspace-policy.sh`, fmt, clippy - on `engine`, `graph`, `source`, `host-core` (`--all-targets --all-features -D warnings`): pass. - -**Edits outside the original authorized paths, and who accepted them.** -- `crates/engine/src/realtime/plan.rs`: the read-only `PreparedPlanExecutor::as_any` and - `PreparedRenderPlan::executor_any` (control-side, no render code); one attempt, accepted by root - on 2026-10-16. -- `crates/host-core/src/lib.rs` (re-exports) and `crates/host-core/src/render_session.rs` (the - `carried_base` field): mechanical. -- `crates/capi/tests/resource_lifecycle.rs`: the coordinator added the `carried_base,` - destructure and `assert!(carried_base.is_none())` (mechanical). This corrects the attempt's - record, which said the file was not edited: the attempt did not edit it, the coordinator did. - -**Review fold (batch 15a).** -- M3, gate 2: the session now has track `t` and source `a`, which sort first, and A removes both, - so A's keys are not P0's; A also redeclares `c` and replaces `t0000`'s insert. Under live input - lanes an input-section or strip-stage value edit carries and retargets, so those families (and - meters, delay lines, strip delays, live routes) restart by removal only; the plan's one spectrum - capture carries. The test compares sets, not counts, through two test-support views - (`test_only_inventory_rows`, `test_only_carry_read_keys` in `prepare.rs`): base keys equal read - keys, and base owners equal the owners both plans hold less those A restarted. Red evidence: - `restricted_to` returning the full inventory fails "base keys equal read keys"; - `installed_carry_reads` reporting successor keys fails for each section on its own (sources, - delay lines and strip delays on the owner set; input lanes, strip lanes, meters and live - routes on the key set; effect sites and observation taps on "base rows are P0 rows"; spectrum - on prepare's own pairing assertion). -- M3, gate 1: gate 1's plans are now prepared with live controls, so inserts bank with live - lanes and can carry. `t3` carries a transient shaper (audible state, its own bank), and every - input section carries its high-pass state. Red evidence: dropping the effect-lane or - input-section rows from the restriction fails gate 1. The reviewer's "an unrestricted base is - red" assertion is not added. That assertion and root's item 7 cannot both hold: the restricted - base and P0's full inventory differ only in rows A restarted or removed, and an owner from - those rows carries from P0 only when B reverts it to its P0 form. So the assertion would lock in - the behaviour that root ruled a defect (an owner or source that A restarts and B reverts starts - fresh in B). Item 7 is not small, and it is not coded here, for three reasons. (1) The fix - removes the restriction: the control plane passes P0's inventory as the base, which reverses D1 - and D2. (2) It needs a P0 producer pass before the donor pass: for a source that A redeclared and - B reverts, the donor's producer feeds A's ring and not the P0 ring that B reads. (3) It must - decide where host submits made between A's commit and B's swap go. Successor placeholder (the - coordinator writes it): *B carries an owner or source it keeps unchanged from P0 from P0's state*. -- m1: `donate_producers` now moves the donor's producer into the successor and leaves the donor - vacant, so a later submit or seek to the donor is refused (`Vacated`) and never acked. The - successor's own fresh producer is dropped. `check_donation` refuses a successor producer that - has already been fed with the new `DonationError::SuccessorFed`, so that drop never loses acked - PCM. `apply_donation` first re-reads both halves' preconditions (producer pairs resolve, - `can_donate_sources`) and moves nothing if either fails. It then runs the consumer swap (all or - nothing) and, only if that moved, the producers, so a donated ring and its producer are never - separated. Test: `a_donation_never_separates_an_acked_ring_from_its_producer`, and gate 4 now - asserts the donor's `Vacated`. Red evidence: restoring the producer swap fails gate 4; removing - the fed check fails the new test; the old producers-first order fails the new test. -- m2: `crates/source` tests `a_source_swap_refuses_every_unswappable_pair_and_moves_nothing` - (every `swappable` refusal: either index out of range, either entry vacant, channel counts, quanta, - either index repeated, a foreign driver; nothing moves) and - `a_source_swap_carries_a_prepared_seek_into_the_next_block`. Each `swappable` condition removed - on its own and the `pending_generation_change` OR removed are each red. The OR stays set-wide. - A comment at the OR records why this is safe. It can only over-report one block's - generation-change validity and never hides a real change. It cannot be reached in production, - because a donor never had `prepare_source_seek`. A per-entry flag would add reads on render. - -**Open, outside this slice's paths (resolved).** `crates/capi/tests/resource_lifecycle.rs:810` -destructures `PreparedHost` exhaustively and needed `carried_base,` plus -`assert!(carried_base.is_none())`. The attempt did not edit it (batch 15a rule for `crates/capi`). -The coordinator added it, as recorded above. - -**Review fold 2 (batch 15a).** -- *m-2.* A release-build probe fed B's producer between `check_donation` and `apply_donation` and - lost the acked chunk: `producers_resolve` did not re-read `unfed`. It now requires the - successor's producer to be unfed, so the apply refuses and moves nothing. `donate_producers` and - `donate_consumers` are `pub(crate)` (no caller outside `host-core`) and no longer re-exported. - Test `a_successor_fed_between_the_check_and_the_apply_moves_nothing`: apply returns 0, both - sides keep their rings, B's producer continues after its acked chunk and A's is not vacated. - Red with the `unfed` re-read removed: the `debug_assert!` in `donate_producers` (debug) and - "a fed successor refuses the apply" (release). -- *m-3.* `a_seek_or_an_empty_end_of_region_on_the_successor_refuses_the_donation`: a seek alone - (generation 2) and a seek to the region's end closed by a zero-frame end-of-region chunk each - give `SuccessorFed`. Red with `unfed` reading only the written frames ("seek only"). A - zero-frame end-of-region chunk at generation 1 is not reachable: the validator refuses a - zero-frame source and the ring refuses the chunk as non-contiguous, so the chunk follows a seek. - The removed pre-check is caught by the m-2 test above. -- *m-4.* The doc of `a_donation_never_separates_an_acked_ring_from_its_producer` no longer claims - the old producer swap; it names gate 4's `Vacated` assert - (`a_donated_ring_plays_its_acked_pcm_and_held_seek`) as the catch. -- *Reverted-source guard (root ruling of 2026-10-16).* `host_core::drops_acked_reverted_source` - (`source.rs`, beside `check_donation`, reusing `unfed`) reports a source the donor redeclared - from the running plan, fed since, that the successor declares as the running plan does: the one - case the donation cannot move and the acked chunks would drop. #1310's rebuild refuses it as - `BACKPRESSURE` (see #1310's fold 2); #1698 lifts it. -- *Fold 3 (M-A, root ruling of 2026-10-16).* The guard is widened and renamed - `host_core::drops_acked_fresh_source(donor side, &DonationPlan)`: any fed ring the donor created - (`can_prepare_source_seek` on the donor, as in `check_donation`) that the donation does not move - is refused, so B reverting, redeclaring a third way or removing the source, and A adding it, are - all covered; a source the donor carried from P0 is not. Unit test - `withdrawn_successor::the_guard_refuses_exactly_a_fed_fresh_ring_the_donation_leaves_behind`; - see #1310's fold 3. - -## Amendment (#1698, batch 15b, coordinator ruling of 2026-10-16 confirmed by root) - -D1 and D2 are amended. A replacement B across a withdrawn A is prepared from P0's own inventory -(`PlanStateInventory::across_withdrawn(&A.inventory, A.plan)`), not from A's restricted base, so -an owner B keeps unchanged from P0 carries from P0 even when A restarted or removed it. Source rows -keep only the sources A carries from P0, so the source carry is what this issue shipped. -`PreparedHost::carried_base`, `PlanStateInventory::restricted_to`, `carried_base_retained_bytes`, -the test-support views and `graph::installed_carry_reads` are removed. Gate 2 -(`the_carried_base_holds_exactly_the_rows_the_hand_over_reads`) is deleted with them. D3 (the -donation) is unchanged. See #1698's decision record. diff --git a/.github/ISSUE_SPECS/1396-give-a-plan-a-source-read-clock-that-leads-its-render-clock.md b/.github/ISSUE_SPECS/1396-give-a-plan-a-source-read-clock-that-leads-its-render-clock.md deleted file mode 100644 index 162855cea..000000000 --- a/.github/ISSUE_SPECS/1396-give-a-plan-a-source-read-clock-that-leads-its-render-clock.md +++ /dev/null @@ -1,297 +0,0 @@ -# Give a plan a source-read clock that leads its render clock - -Stream C of decision 15 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`, D15-8 (round-5 amendment), D15-12, D15-17). -Slice of *Grow latency during playback by adopting a primed warm successor* (#1287), W3 (clocks). -Split from *Adopt a warm successor with a raw-frame prime at the first ready block* (#1355), which -depends on it. -Code anchors verified on `main` at `6fb211594`. - -## Product outcome - -A plan can read its sources ahead of its render clock by a fixed offset, and that offset never -moves backwards on an ordinary rebuild. Seeks anchored on the source-read clock (D15-12) apply in -the right block after any number of rebuilds. Only a host-declared discontinuity resets the offset, -and the source PCM then continues with no frame repeated or skipped. The control thread can read -both clocks. - -## Context - -- `RenderTime` has one field, `absolute_sample` (`crates/engine/src/realtime/plan.rs:497-500`). - The graph executor passes it to the source section: - `source_set.begin_block(time.absolute_sample, ...)` (`crates/graph/src/lib.rs:3256`). The - source driver hands it to every consumer: `consumer.begin_block_at(first_sample)` - (`crates/source/src/lib.rs:1701-1721`). The consumer's own `next_frame` decides which PCM plays; - the block sample decides only when a held anchored seek applies (`begin_block_at`, `:1115`). -- `RenderTime { .. }` literals appear in about 50 files (tests, tools, hosts). This slice does not - change the struct, so none of them move. -- `PreparedRenderPlan` has one clock, `next_absolute_sample` (`plan.rs:609`). `render_contiguous` - (`:870-883`) refuses any other sample, and the C ABI host passes the sample - (`miso_engine_v1_render_f32_planar`, `crates/capi/src/ffi.rs:807`). So the render clock belongs - to the host and never jumps. At a swap the incoming plan adopts the outgoing clock - (`adopt_absolute_sample`, `plan.rs:617`; `RealtimePlanOwner::enter_block`, - `crates/engine/src/realtime/plan_exchange.rs:375-437`; `adopt_predecessor_plan`, `plan.rs:899`). -- `PreparedPlanExecutor` (`plan.rs:269`, defaulted methods at `:271-295`) has one production - implementation, `GraphExecutor` (`crates/graph/src/lib.rs:3124`). -- The successor entry points take a `SuccessorBase { inventory, committed }` - (`crates/host-core/src/prepare.rs:641-647`). `PlanStateInventory` (`:558-564`) is the - predecessor's control-side record; every successor is prepared from it. -- `plan_exchange` (`plan_exchange.rs:194`) allocates the shared counters that publisher and owner - hold; `plan_exchange_resource_report` (`:163-191`) counts them row by row. -- *Anchor every seek on the plan's source-read clock* (#1316) D1 renames `begin_block_at`'s - parameter `source_read_sample` and leaves this issue to make it differ from the render clock. -- *Reset latency floors at a host-declared discontinuity* (#1323) D3 adds - `SuccessorBase::discontinuity`. The declaration does not say whether a seek follows. - -## Decisions frozen for this slice - -- **D1. Two clocks.** `PreparedRenderPlan` gains `source_read_offset: u64` (0 by default), with - `source_read_offset()` and `set_source_read_offset(&mut self, offset)`. The setter is a - control-thread call on an unpublished plan; it forwards to a new defaulted trait method - `PreparedPlanExecutor::set_source_read_offset(&mut self, offset: u64) {}`. `GraphExecutor` stores - it, and `crates/graph/src/lib.rs:3256` passes `time.absolute_sample + offset` (a `checked_add`; - overflow is `RenderError::TimeOverflow`) as the source-read sample. `RenderTime` is unchanged. - `ΣP` names the running plan's offset. -- **D2. Inheritance.** `PlanStateInventory` records its plan's offset. Host-core sets every - successor's offset at preparation from `base.inventory`: - - an ordinary successor (a rebuild, a transition, a re-preparation): the predecessor's offset; - - a discontinuity successor (`base.discontinuity`, #1323 D3): 0; - - a warm successor: the predecessor's plus its `lead_samples` (*Prepare a warm successor whose - carried nodes lead the predecessor by P*, #1354). That case is *Adopt a warm successor with a - raw-frame prime at the first ready block* (#1355), on top of this rule. - - So the source-read clock is monotone except at a declaration. - - **The declaration's offset term.** This slice adds the term #1323 D2 leaves to it: the - declaration also rebuilds when the newest plan (the pending candidate, or else the active - plan) has `source_read_offset() > 0`, read from its inventory row, even when no floor is - raised. After a warm growth a later edit can make every node's natural arrival equal its - floor while the plan still reads ahead; without the term that declaration publishes nothing - and `ΣP` never resets. The test stays in `crates/control-plane/src/` beside #1323's floor - term. -- **D3. Stop without a seek.** At the discontinuity successor's adoption block the source-read clock - steps from `render + ΣP` to `render`. Each consumer keeps its `next_frame`: no frame is rewound, - repeated or skipped. A stop that is not followed by a seek therefore resumes the stems where - render's consumers stood, which is `ΣP` later in content than the last audible output. A - declared stop owes no continuity. A held anchored seek keeps its anchor and compares it with the - new clock. This step is the only backwards move of the source-read clock, and only a host - declaration causes it. -- **D4. Readers.** `plan_exchange` allocates one more shared record, two `AtomicU64` - (`render_clock`, `source_read_clock`), on the control thread; it is a new row in - `plan_exchange_resource_report`. After each rendered block, render stores the active plan's next - render sample and that sample plus its offset (`Release`). `PlanPublisher::render_clock()` and - `PlanPublisher::source_read_clock()` each load one word (`Acquire`), so neither read can tear. - Before the first block both read the initial plan's values. The warm-successor deadline - (*Fall back to the transition when a warm successor is not ready by its deadline*, #1358) uses - the render clock; hosts anchor seeks on the source-read clock, which jumps by `P` at a warm - adoption (#1287, the lemma's seeks and clock). -- **D5. Acked-batch question.** No queue changes. An offset moves no PCM. An ack can never precede - a drop. - -## Deliverables - -1. D1 and D4 in `crates/engine/src/realtime/plan.rs` and `plan_exchange.rs` (re-exports in - `mod.rs`), and the one call in `crates/graph/src/lib.rs`. -2. D2 in `crates/host-core/src/prepare.rs`, with a `test-support` function - `test_only_set_source_read_offset(&mut PreparedHost, u64)` that sets the plan's offset and its - inventory row together. -3. D2's declaration term in `crates/control-plane/src/`, with a `test-support` hook that applies - `test_only_set_source_read_offset` to the next plan the session prepares. -4. Gates in `crates/host-core/tests/source_read_clock.rs` (new), the engine's `plan_exchange.rs` - unit tests and the control-plane crate's unit tests (gate 5). - -## Authorized paths - -- `crates/engine/src/realtime/plan.rs`, `plan_exchange.rs`, `mod.rs` -- `crates/graph/src/lib.rs` (`GraphExecutor`'s offset field and the source-section call only) -- `crates/host-core/src/prepare.rs`, `crates/host-core/tests/source_read_clock.rs` (new) -- `crates/control-plane/src/` (the declaration's offset term, its `test-support` hook and gate 5) - -## Non-goals - -- No readiness check, prime or adoption, and no warm offset (#1320, #1355). No floor logic - (#1285, #1323). -- No change to `RenderTime`, to the render clock's contiguity rule, or to any host's render call. -- No browser or C ABI surface for the clocks; hosts reach them through the control plane later - (#1360, #1361). - -## Objective gates - -1. **Anchor on the source-read clock (the old #1287 gate 4, per D15-12).** A plan prepared with - offset `3 * 128` at 48 kHz, quantum 128. A `seek_at` anchored at source-read sample `A` applies - in the block whose source-read sample is `A`, that is render sample `A - 384`; the output - equals a zero-offset reference whose anchor is `A - 384`. -2. **Ordinary rebuild inherits.** From gate 1's plan, an ordinary successor (a fader-only prepared - change) reports offset `384`. A `seek_at` held across the swap applies in the same block as in - a run without the rebuild. -3. **Declaration resets, no frame lost.** From gate 1's plan, a successor prepared with - `discontinuity: true` reports offset 0. Its first block plays each source's next frame after - the predecessor's last played frame: none repeated, none skipped (a ramp-PCM source whose - sample values equal their frame index). `source_read_clock()` equals `render_clock()` from that - block on. -4. **Readers.** Through 8 blocks and two swaps (offset 384, then 0), after each block - `render_clock()` equals the plan's next sample and `source_read_clock()` equals it plus the - active plan's offset (engine unit test with a test executor). -5. **A declaration resets an offset with no raised floor.** Control-plane unit test with - `test-support`, 48 kHz, quantum 128. A session's active plan has no floor above any natural - arrival and offset 384 (set by D2's hook before publication). The control plane's declaration call (#1323 D2) - publishes one plan (the swap counter grows by one), and after one render the active plan reports offset 0 - and `source_read_clock()` equals `render_clock()`. With offset 0 instead, it publishes nothing - (#1323 gate 3). -6. Commands: - - `cargo test --locked -p engine --features engine/realtime-audit` - - `cargo test --locked -p graph`, `cargo test --locked -p source` - - `cargo test --locked -p host-core --features host-core/test-support` - - `cargo test --locked -p control-plane --features control-plane/test-support` - - `cargo run --locked --release -q -p realtime-policy`, `bash scripts/check-workspace-policy.sh` - - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `cargo fmt --all -- --check` - -## Test value - -- Gate 1: a seek compared on the render clock applies 3 blocks early. Red. -- Gate 2: an ordinary successor that defaults its offset to 0 applies the held seek `ΣP` late. Red. -- Gate 3: a reset that rewinds or advances a consumer repeats or skips frames. Red. -- Gate 4: a reader that stores the offset from the outgoing plan reports the wrong source-read - sample after a swap. Red. -- Gate 5: a declaration test that checks floors only publishes nothing for a plan that leads with - no raised floor, so `ΣP` is never reset. Red. - -## Dependencies - -- *Anchor every seek on the plan's source-read clock* (#1316). -- *Reset latency floors at a host-declared discontinuity* (#1323): `SuccessorBase::discontinuity`. - -## Amendment (controls coordinator, batch 15 check, 2026-10-15) - -Checked against `228fca26c`. - -**Moved anchors.** Each fact in Context still holds; only the lines moved. - -- `RenderTime` is `crates/engine/src/realtime/plan.rs:576-579`. The plan's clock field is `:755`, - its getter `:910`, `adopt_absolute_sample` `:918`, `render_contiguous` `:1172-1185`, - `adopt_predecessor_plan` `:1201-1207`. -- `PreparedPlanExecutor` is `plan.rs:273`; its defaulted methods now run to about `:360` and - include #1311's `prime_ready` (`:349`). -- The graph's source-section call is `crates/graph/src/lib.rs:4843-4844`, in the block entry that - #1639 changed (the jump-length read is `:4823-4826`, just before it). `GraphExecutor` is - `:3192`, its `PreparedPlanExecutor` impl `:4588`. -- The source driver's block entry is `crates/source/src/lib.rs:1752-1785`; the - `begin_block_at(first_sample)` call is `:1772`. `begin_block_at` is `:1115`. -- `miso_engine_v1_render_f32_planar` is `crates/capi/src/ffi.rs:818`. -- `RealtimePlanOwner::enter_block` is `plan_exchange.rs:742-795`; the clock adoption is `:779`. - `plan_exchange` is `:367`, `plan_exchange_at_revision` `:410`, and both call - `plan_exchange_inner` (`:436-470`), which is where D4's record is allocated. - `plan_exchange_resource_report` is `:337-361`. -- `SuccessorBase` is `crates/host-core/src/prepare.rs:1070-1097`. It now has `inventory`, - `committed`, `revision` (#1277), `edit_ramps` (#1394), `monitor` (#1559) and `observation` - (#1584). It has no `new` constructor yet; #1323 D3 adds it. `PlanStateInventory` is `:711-757`. -- The control plane's successor construction site is `crates/control-plane/src/control.rs:1670`. - -**Code notes for the implementer.** - -- `GraphExecutorWithoutSplitPairTable` (`crates/graph/src/lib.rs:4438`) mirrors - `GraphExecutor`'s layout for the largest-allocation cap. D1's new offset field goes in both. - This is inside D1's "offset field" scope. -- D4's new record and D1's field can move pinned measurements outside the authorized paths: - `crates/capi/tests/resource_lifecycle.rs` (the capi retained measurement) and - `hosts/host-web/tests/browser-v1/expected.json` (browser boot memory). The control plane - charges the exchange through `crates/control-plane/src/compile.rs:237`. If a pin moves, re-pin - it from the measurement with the reason, as batch 13a and 13b did. Add those two files to the - authorized paths for that purpose only. -- #1637 shipped node time as `s = r + ΣP - a(n)`. No code reads `ΣP` yet, so it is 0 today. - *Build the timeline consumer and producer* (#1640) and *Give every plan a timeline that - carries like a source* (#1641) will pass this slice's source-read sample to the timeline. - Nothing in this slice changes for that. -- Host-core builds a source set for every plan (`crates/host-core/src/prepare.rs:2579`), with no - entries for an empty session (#1682). D1 then offsets a clock that no source reads, and D4 - still publishes both clocks. - -**Dependencies on GitHub.** - -- #1316 and #1323 are open. Both are in the batch-15/16 list. -- #1323 depends on *Supersede an unadopted candidate plan by compare-and-swap* (#1310, open), - and #1310 depends on #1344 (open). Neither is in the batch-15/16 list. So D2's declaration - term and gate 5 cannot run until #1310 and #1323 land. -- Batch order (root, 2026-10-15): batch 15a is the controls lane, #1344, #1398 and #1310. - #1324 left batch 15a (root ruling, 2026-10-15); it lands in a later batch after #1288, #1391, - #1363 and #1325. - The rest of batch 15 follows. #1323 lands after #1310 (B15-R5 in #1323). -- #1314 and #1309 are closed. The control plane is `crates/control-plane/src/`, as the spec says. -- #1354, #1355, #1320, #1358, #1360 and #1361 are open. They are follow-ons or non-goals, not - dependencies. - -**Decisions.** - -- D1-D5 still hold against the code and decision 17. The clocks get no host surface here, and a - later host surface goes through the control plane (decision 17, C1). -- #1323 D1 adds a C export (`miso_engine_v1_declare_discontinuity`). Decision 17 C13 routes - new features through protocol messages. That is #1323's question, not this slice's: gate 5 - calls the control plane, not the export. - -**Gates and commands.** `crates/host-core/tests/source_read_clock.rs` is new. Every named script -and package exists (`realtime-policy` is `tools/realtime-policy`). The features -`engine/realtime-audit`, `host-core/test-support` and `control-plane/test-support` exist. - -**Realtime-path edits.** D1 and D4 edit `crates/engine/src/realtime/plan.rs`, -`plan_exchange.rs` and `mod.rs`. D1 also edits the graph's render block entry. That slice gets -one attempt. - -## Amendment (controls coordinator, batch 15a fold, 2026-10-16) - -- **Batch placement.** "Neither is in the batch-15/16 list" (Dependencies on GitHub, about #1310 - and #1344) is void. #1310 and #1344 are batch 15a; #1323 lands after #1310 (B15-R5). - -## Decision record: #1396 (batch 15c) - -**Shipped.** - -- D1: `PreparedRenderPlan::source_read_offset()` and `set_source_read_offset()` (control thread, - unpublished plan), forwarding to the new defaulted `PreparedPlanExecutor::set_source_read_offset`. - `GraphExecutor` (and its layout mirror `GraphExecutorWithoutSplitPairTable`) stores it, and the - source section's block entry reads from `time.absolute_sample.checked_add(offset)`; overflow - silences the block and returns `RenderError::TimeOverflow`, as the section's other refusals do. - `RenderTime` is unchanged. -- D2: `PlanStateInventory` records the offset (`source_read_offset()`); preparation sets every - plan's offset from `base.inventory` (0 for a first plan; `at_discontinuity` gives 0) and writes - the same value to the plan. `host_core::test_only_set_source_read_offset` (test-support) sets - plan and row together. -- D2's declaration term: `PlanStateInventory::runs_behind_natural_timing()` is now "a raised floor - or an offset above 0", the one test `SessionState::declare_discontinuity` already calls (#1323's - note and the method's own doc named this place). The control-plane hook is - `SessionState::test_set_next_source_read_offset` (a thread-local taken once by - `prepare_runtime`), with `test_render_clock` / `test_source_read_clock` readers, all test-support. -- D4: `plan_exchange` allocates one `Arc` clock record (two `AtomicU64`), a new row of - `plan_exchange_resource_report`. After each rendered block render stores the active plan's - source-read clock, then its render clock (`Release`); `PlanPublisher::render_clock()` and - `source_read_clock()` load one word each (`Acquire`). Before the first block both hold the - initial plan's values. No host surface (non-goal; #1360, #1361). - -**Departures.** - -- Gate 2's "fader-only prepared change" is a fader-gain change on a muted second track, so the - rebuilt run is bit-comparable with the run without the rebuild. -- The declaration term lives in host-core's `runs_behind_natural_timing` (called from the control - plane), not in a second test inside `crates/control-plane/src/`; gate 5 is in the control plane. -- Mechanical edits outside the paths: the re-export in `crates/host-core/src/lib.rs`, and two - comments in `crates/source/src/lib.rs` that said "#1396 passes the source-read sample here". -- The offset is nonzero only through the test hook until #1355, so the header's "equal to the - render clock in this version", `docs/C_ABI_V1_QUALIFICATION.md` and the `seek_at` doc stay true - and are unchanged. - -**Gates.** - -- Gate 1: `crates/host-core/tests/source_read_clock.rs::a_seek_applies_on_the_source_read_clock`. -- Gate 2: `an_ordinary_successor_inherits_the_offset` (same file). -- Gate 3: `a_declaration_resets_the_offset_and_loses_no_frame` (same file; ramp PCM). -- Gate 4: engine `realtime::tests:: - the_publisher_reads_both_clocks_of_the_active_plan_after_each_block`. -- Gate 5: control-plane - `control::tests::source_read_offset::a_declaration_resets_an_offset_with_no_raised_floor`. -- Red evidence: the graph passing the render sample turns gate 1 red; an ordinary successor at - offset 0 turns gate 2 red (also by its output alone); a discontinuity successor that keeps the - offset turns gate 3 red; render storing the outgoing plan's lead turns gate 4 red (checked in a - scratch copy); dropping the offset term turns gate 5 red. - -**Measured.** `realtime-policy` ok with no floor change (151 regions in 33 files). Audit capi -`pcm_digest` 4f52089e4a8cf83c, unchanged, zero violations. The capi `resource_lifecycle` ceilings -and the browser `expected.json` rows hold without a re-pin (`check-browser-expected-resources.py ---artifacts` green; the clock record is 32 bytes native, 24 on wasm32). diff --git a/.github/ISSUE_SPECS/1398-size-the-c-abis-plan-capacities-and-resource-admission-for-a-superseding-candida.md b/.github/ISSUE_SPECS/1398-size-the-c-abis-plan-capacities-and-resource-admission-for-a-superseding-candida.md deleted file mode 100644 index a86ea1c28..000000000 --- a/.github/ISSUE_SPECS/1398-size-the-c-abis-plan-capacities-and-resource-admission-for-a-superseding-candida.md +++ /dev/null @@ -1,325 +0,0 @@ -# Size the C ABI's plan capacities and resource admission for a superseding candidate - -Stream B of decision 15 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`, D15-9, D15-17). -Split from *Supersede an unadopted candidate plan by compare-and-swap* (#1310), which depends on -it. Code anchors verified on `main` at `6fb211594`; the C ABI files cited below move into -`crates/control-plane/src/` with *Extract the C ABI control plane into a portable crate both hosts -call* (#1309), and this slice edits them there. - -## Product outcome - -A structural transaction is never refused because render has swapped plans but not yet published -the new epoch. The C ABI's bookkeeping and resource admission hold every plan and compiled model -that supersession (#1310) or a warm successor's transition re-preparation can keep alive at once: the running plan, -a withdrawn or displaced candidate and its successor, and three compiled models. The header states the sizing rule a host's caps must meet. - -## Context - -- **The lag refusal.** `if self.shared.active_epoch.load(Ordering::Acquire) < self.providers.epoch - { return Err(CommandError::Backpressure) }` (`crates/control-plane/src/control.rs:1028-1035`). Its - comment gives the reason: the retired epoch's report row stays for any-thread readers, so the - report table (capacity 2, `crates/control-plane/src/compile.rs:768`) is full. The control state is - already correct in that window: `synchronize_plan_epochs` (`control.rs:920-961`) reclaims the - retired plan and promotes the pending provider; only the atomic and its row lag. -- **Capacities.** Exchange retirement capacity 1 (`compile.rs:198` for the resource report, - `:762` for the exchange itself), report rows 2 (`:768`), pending providers 1 and retired - providers 1 (`:803-810`). Refusals they cause: `control.rs:1097-1110` (`PublicationFull`, - `RetirementFull`, mapped to `Backpressure`) and `:1128-1132` (report table or pending list full). - `plan_alive == false` (`:1012-1014`) is a separate refusal. -- **Admission.** `validate_replacement_peak` (`compile.rs:371-449`) checks the newest plan's row - plus the candidate; `validate_live_peak` (`:483-520`) checks the current and pending rows plus - the compiled models of `CompiledModelAdmission`. -- The C ABI has no engine default caps: every `CompileLimits` field is caller-chosen and nonzero - (`all_limits_nonzero`, `crates/capi/src/runtime/mod.rs:111-137`). -- **Tests that pin the refusal.** Three issue-#1042 tests run the two halves of a plan-swapping - render call apart and make control calls between them (`crates/capi/src/runtime/tests.rs:1472`): - `control_calls_inside_a_plan_swapping_render_call_keep_replacement_live` (`:1492`), - `a_valid_edit_inside_the_swap_window_is_backpressured_not_compile_rejected` (`:1663`) and - `a_rejected_render_call_that_swaps_plans_leaves_the_c_session_live` (`:1784`). Each asserts a - structural `Backpressure` inside the window. -- *Let the control thread withdraw an unadopted candidate plan* (#1343) replaces the publication - queue with a two-cell mailbox and gives every candidate a retirement credit (its D6). This slice - does not touch publication; it sizes retirement. - -## Decisions frozen for this slice - -- **D1. Capacities.** - - Report rows 4: a lagging epoch's row, the running plan, a withdrawn candidate, its successor. - - Pending providers 2: the withdrawn candidate's epoch and its successor's coexist until #1310 D5 - drops the former. - - Retirement capacity 3 (exchange and resource report alike): a displaced plan not yet - reclaimed, the withdrawn candidate's credit (held until it is dropped or republished), the - successor's reservation. - - Retired providers 2. - - Each capacity's resource row (`compile.rs:197-199` and the capi retained rows that count the - report table and provider lists) follows from its capacity, so the plan resource report states - the new bytes. -- **D2. Delete the lag refusal.** `control.rs:1028-1035` and its comment go. With D1's rows a - candidate's row always fits beside the lagging one, and admission reads the providers, never - the atomic. It is not mapped to another error. -- **D3. Impossible refusals.** With D1, `:1097-1110` and `:1128-1132` cannot fire for a candidate; each - becomes `CommandError::Internal`, with a debug assertion. `plan_alive == false` stays - `BACKPRESSURE`. -- **D4. Admission sums what is held.** `validate_replacement_peak` and `validate_live_peak` take - every plan row the control plane holds unreclaimed (the running plan and every pending or - withdrawn candidate) plus the prospective one, and every compiled model the control plane holds - (including a model #1310 D3 keeps) plus the prospective one. This applies to each row checked - today: `maximum_graph_session_plus_plan_bytes` (plans plus models, `graph.resource.limit`); - `maximum_source_total_bytes` and `maximum_source_overhead_bytes` (a carried ring counted once, - `source.resource.limit`); `maximum_effect_state_bytes` and `maximum_effect_scratch_bytes` - (`effect.resource.limit`); `maximum_builtin_retained_bytes` and `maximum_capi_retained_bytes` - (`capi.resource.limit`). The largest-allocation check folds every held row. Before #1310 the - held set is at most two plans, so no current admission changes. - - **Re-preparation peak: dropped.** A second peak for a warm candidate's re-preparation - (`AdmissionPeak::WithReprepare`) is not in this slice; see the root ruling in the decision - record. #1403 adds it with its first caller. -- **D5. Sizing rule and reference limits.** The header states it: each cap above must be at least - three times one plan's row, and the graph cap also covers three compiled models. The repository's - reference limits must admit three plans of their sessions: `audit_limits` - (`tools/audit/src/capi.rs:579`), `limits` in `crates/capi/tests/plan_swap_race.rs:33` and in - `crates/capi/tests/resource_lifecycle.rs:160`. Any that does not is raised here. The browser's - caps follow the same rule when it adopts the control plane (#1332). -- **D6. Acked-batch question.** D2 removes a refusal; the only newly acked edits are ones that fit. - No queue is added. An ack can never precede a drop. - -## Deliverables - -1. D1-D4 in `crates/control-plane/src/` (the moved `control.rs` and `compile.rs`). -2. D5's header paragraph and `docs/C_ABI_V1_QUALIFICATION.md` sentence; any raised reference limit. -3. Gates below. - -## Authorized paths - -- `crates/control-plane/src/` (package `control-plane`, created by #1309). -- `crates/capi/include/miso_engine_v1.h` (prose only), `crates/capi/src/runtime/tests.rs`, - `crates/capi/tests/plan_swap_race.rs` and `crates/capi/tests/resource_lifecycle.rs` (limits and - the assertions named below), `tools/audit/src/capi.rs` (limits only), - `docs/C_ABI_V1_QUALIFICATION.md`. - -## Non-goals - -- Supersession itself (#1310). The mailbox and credits (#1343). The browser's caps (#1332). - -## Objective gates - -1. **No lag refusal (rewrite the three #1042 tests).** In each of the three tests named in Context, - the structural edit made inside the window now returns OK, its plan is adopted at the next - block, and the report table holds three rows meanwhile. Each test keeps its other assertions - (no `Internal`, no provider parked for good, both source submits land in the carried ring). - `a_valid_edit_inside_the_swap_window_is_backpressured_not_compile_rejected` is renamed - `a_valid_edit_inside_the_swap_window_is_admitted_against_the_running_plan`: with its limit - (nine EQs beside eight fit, nine beside nine do not), the edit inside the window is admitted. -2. **Admission sums every held row (new control-plane unit test).** Three held rows and three - models, with each cap in D4 one byte below the sum in turn, refuse with that cap's diagnostic; - at the sum they pass. With one held row the result equals today's for the same inputs. -3. **Dropped** (root ruling 2026-10-15, decision record): the re-preparation peak moves to #1403. -4. **Reference limits.** `./target/release/audit capi` and the two capi integration tests pass - with their limits (raised only if needed, D5). -5. Commands: - - `cargo test --locked -p capi` - - `cargo test --locked -p control-plane --features control-plane/test-support` - - `cargo build --locked --release -p audit -p capi && ./target/release/audit capi` - - `bash scripts/check-capi-abi.sh`, `bash scripts/check-capi-abi.sh --self-test` - - `bash scripts/check-workspace-policy.sh`, `cargo fmt --all -- --check`, - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings` - -## Test value - -- Gate 1: the lag refusal kept, or a report table one row short, refuses the edit in the window; - an admission that still pairs with the atomic's row compile-rejects it. These three tests are - rewritten, not added: their old `Backpressure` assertions are the superseded ones. -- Gate 2: an admission that forgets a held plan or model (a missing third term) passes one byte - over the cap. -- Gate 3: an admission that checks a warm candidate only against the plans held at submit admits a - warm candidate whose transition re-preparation (three plans) exceeds the caller's caps, so the - deadline fallback would be refused and the edit could neither adopt nor fall back. - -## Dependencies - -- *Extract the C ABI control plane into a portable crate both hosts call* (#1309). - -## Amendment (controls coordinator, batch 15 check, 2026-10-15) - -Checked against `e2f6cfb24`. - -**Batch order (root, 2026-10-15).** Batch 15a is the controls lane: #1344, #1398, #1310, in -that order. #1324 left batch 15a (root ruling, 2026-10-15); it lands in a later batch after #1288, -#1391, #1363 and #1325, in that order. The rest of batch 15 follows. - -**Moved anchors.** #1309 has landed; the files are in `crates/control-plane/src/` as the spec -expects. Each fact in Context still holds; only the lines moved. -- The lag refusal and its comment are `control.rs:1579-1586`. `synchronize_plan_epochs` is - `:1401`. -- `plan_alive == false` is `control.rs:1541-1543`. -- The reservation refusals are `control.rs:1751-1765`. Since #1343 and #1311 the error has four - arms: `PublicationFull`, `RetirementFull`, `Incompatible` and `EpochExhausted`. D3 covers the - first two only; the other two stay as they are. -- The full report table or pending list is `control.rs:1781-1788`. The one-candidate refusal - `if !self.pending_providers.is_empty()` is `:1742-1744`; it is #1310's, not this slice's. -- Capacities in `compile.rs`: the exchange resource row's retirement capacity `:237-239`; the - exchange's own `:1121` (`one`, `:1092`); report rows `:1125-1128`; pending providers - `:1168-1171`; retired providers `:1172-1175`. -- `validate_replacement_peak` is `compile.rs:646`; `validate_live_peak` is `:770`; - `CompiledModelAdmission` is `:82`. -- `all_limits_nonzero` is `crates/capi/src/runtime/mod.rs:144-169`. -- The three #1042 tests are `crates/capi/src/runtime/tests.rs:1715`, `:1885` and `:2005`; the - outcome helper is `:1687-1706`. Each still asserts `Backpressure` in the window. -- `audit_limits` is `tools/audit/src/capi.rs:599`. `limits` is `crates/capi/tests/plan_swap_race.rs:56` - and `crates/capi/tests/resource_lifecycle.rs:176`. - -**Code notes for the implementer.** -- D1 holds against #1343. The exchange's `retirement_capacity` now counts retirement credits: - reserved candidates, unadopted candidates and retired plans together - (`crates/engine/src/realtime/plan_exchange.rs:70-71`). D1's three are exactly those. Only the - config value changes; `plan_exchange.rs` does not. -- D4 misses one admission term. #1290a D3 added `ControlLimits::maximum_replacement_peak_bytes` - (`crates/control-plane/src/adapter.rs:81-87`), checked in `validate_replacement_peak` as - `control.resource.replacement_peak` (`compile.rs:686-697`). It sums graph, models and - allocated source bytes. D4's "every held row" rule applies to it too, and gates 2 and 3 cover - it. The C ABI sets it to `u64::MAX` (`crates/capi/src/runtime/mod.rs:211`); the browser sets - its one budget (`hosts/host-web/src/lib.rs:5081`, and `set_replacement_peak_limit` at - `:6347`). -- D4's `maximum_capi_retained_bytes` is `ControlLimits::maximum_control_retained_bytes` in the - control plane (`adapter.rs:59`); the C ABI maps one to the other (`runtime/mod.rs:199`). - -**Decisions.** D1-D4 and D6 hold against the code and decision 17. D5 does not hold as written. -- **ROOT RULING (2026-10-15): this slice covers both hosts.** The finding follows; the ruled - text is after it. The browser now runs this control plane. D5 says "The browser's caps - follow the same rule when it adopts the control plane (#1332)", and the non-goals leave the - browser's caps to #1332. Since #1381a the browser runs `control_plane::SessionState` in its one - instance (`hosts/host-web/src/lib.rs:6-16`). So D1's capacities and D4's sums reach the browser - in this slice, through the same code (decision 17 C1). The browser's limits are its one memory - budget (`control_limits`, `hosts/host-web/src/lib.rs:5053-5083`). D1's larger tables add fixed - bytes there, and browser byte pins can move. Proposed: this slice covers both hosts. D5's - browser sentence becomes: "The browser's caps are its memory budget; its replacement-peak term - must cover the three-plan peak when #1403 admits a warm candidate." Add to Authorized paths, - for moved pins only, with the measured reason: `hosts/host-web/src/tests.rs`, - `hosts/host-web/tests/` and `hosts/host-web/tests/browser-v1/expected.json`. Gate 4 adds - `cargo test --locked -p host-web --features host-web/test-support`. - The ruled text, which supersedes D5's browser sentence and the non-goal "The browser's caps - (#1332)": - - **D5, browser sentence (restated).** The browser's caps are its one memory budget - (`control_limits`, `hosts/host-web/src/lib.rs:5053-5083`). D1's larger tables are charged - in that budget, through the same control-plane code as the C ABI. Its replacement-peak term - (`set_replacement_peak_limit`, `:6347`) must cover the three-plan peak when #1403 admits a - warm candidate. This slice adds no browser cap and no browser default. - - **Host-web test (gate 1b).** A new test in `hosts/host-web/tests/plan_exchange_threads.rs` - is the browser twin of gate 1's first test. Boot, commit a structural edit (add a muted - track), and render the block that adopts it. Before the epoch atomic is published, submit a - second structural edit through the browser's control path. It returns OK (the base returns - `BACKPRESSURE`), its plan is adopted at the next block, and the active epoch then equals the - second plan's epoch. To hold the window, drive the render's two halves through - `control_plane::PlanState::test_owner_mut` and `test_active_epoch` (`crates/control-plane/src/plan.rs:219-229`). - If host-web cannot reach them, add one `#[cfg(feature = "test-support")]` accessor beside - `active_plan_epoch_for_test` (`hosts/host-web/src/lib.rs:2297`). Test value: the lag refusal - kept on the browser path, or a browser budget that does not charge D1's tables and so refuses - the second plan, turns it red; gate 1's C ABI tests do not boot the browser. - - **Authorized paths (added).** `hosts/host-web/tests/plan_exchange_threads.rs` (the new test); - `hosts/host-web/src/lib.rs` (the test-support accessor only); and, for moved pins only, each - with its measured reason in the PR: `hosts/host-web/src/tests.rs`, `hosts/host-web/tests/` - and `hosts/host-web/tests/browser-v1/expected.json`. - - **Gate 4 (added command).** `cargo test --locked -p host-web --features host-web/test-support` - passes, gate 1b included. - -**Dependencies on GitHub.** -- #1309 is closed. #1343 and #1311 are closed. -- Downstream: #1310 (batch 15a). #1403, #1358 and #1397 are open and not in batch 15; #1403 adds the - re-preparation peak with its first caller (root ruling 2026-10-15, decision record). - -**Gates and commands.** Every named command, package, feature and script exists. Gate 4's -`./target/release/audit capi` is built by gate 5's `cargo build` line. - -**Realtime-path edits.** None under `crates/engine/src/realtime`. The capacities are control-side -configuration (`PlanExchangeConfig`), read at boot. - -## Decision record: #1398 (batch 15a) - -**Shipped.** -- D1 in `crates/control-plane/src/compile.rs`: `REPORT_ROWS = 4`, `PENDING_PROVIDERS = 2`, - `RETIRED_PROVIDERS = 2`, `RETIREMENT_CAPACITY = 3` (exchange and its resource report alike). The - capi fixed rows charge the pending and retired lists as two rows (they are two allocations) and - the report table at 4. -- D2: the lag refusal is deleted (`control.rs`), not mapped to another error. -- D3: `PublicationFull` and `RetirementFull` at reservation, and a full report table or pending - list at commit, return `CommandError::Internal` with a `debug_assert!`; `Incompatible`, - `EpochExhausted` and `plan_alive == false` stay `BACKPRESSURE`. -- D4: `validate_replacement_peak` and `validate_live_peak` take every held plan - (`SessionState::held_epochs`: the running plan, then every pending candidate, from the report - table's provider rows, never the atomic's; a fixed array, no allocation) and every compiled model - (`compiled_model_admission_over`). The replacement admission sums every D4 term including - `maximum_replacement_peak_bytes`; the capi term is the newest held row plus every older held - epoch's `epoch_retained` plus the candidate's epoch and prepared-protocol rows (the live - admission's rule). The largest-allocation check folds every held row. (The attempt also added - `AdmissionPeak::{Single, WithReprepare}`; the root ruling below removes it.) -- D5: the header's sizing paragraph sits on `maximum_graph_session_plus_plan_bytes`; - `docs/C_ABI_V1_QUALIFICATION.md` has a #1398 section. No reference limit was raised: the - nine-track EQ session's rows are graph 316,350, source total 12,198, capi 161,258 bytes against - caps of 100,000,000 / 10,000,000 / 10,000,000. -- Gate 1b's accessor is `AudioWorkletEngineHost::render_plan_at_for_test` (`hosts/host-web/src/lib.rs`). - -**Departures.** -- The one-candidate refusal (`if !self.pending_providers.is_empty()`, #1310's) moved from after the - admission to just before it. With D4 summing the pending plan, an edit made while a candidate - waits would otherwise be compile-rejected (permanent) where it was backpressure (retryable), and - `a_valid_edit_inside_the_swap_window_is_admitted_against_the_running_plan` pins that pending - case as `Backpressure`. #1310 replaces that line. -- Gate 1b holds the window with a mistimed render call (the owner adopts the successor, then - refuses the block, so the atomic is not published), through one test-support accessor that uses - only `PlanState::render`. `PlanState::test_owner_mut`/`test_active_epoch` are behind - `control-plane/test-support`, which host-web's `test-support` feature does not enable; enabling - it would turn on the control plane's fault hooks and lifecycle observers in every host-web test - and needs a `Cargo.toml` edit outside the authorized paths. -- `crates/capi/src/runtime/live_peak_tests.rs`: mechanical caller update for the slice signature. -- Gate 1's third test now publishes a fresh candidate after each round (the window's plan is - adopted by the good block, so the next round needs one); its canary assertion is now `false`, - because an admitted command writes its response. - -**Gates.** -- Gate 1: `control_calls_inside_a_plan_swapping_render_call_keep_replacement_live`, - `a_valid_edit_inside_the_swap_window_is_admitted_against_the_running_plan` (renamed), - `a_rejected_render_call_that_swaps_plans_leaves_the_c_session_live` (`crates/capi/src/runtime/tests.rs`): - pass; all three red with the lag refusal restored. -- Gate 1b: `a_structural_edit_inside_the_swap_window_is_admitted_on_the_browser_path` - (`hosts/host-web/tests/plan_exchange_threads.rs`): pass; red (`BACKPRESSURE`) with the lag - refusal restored. -- Gate 2: `compile::admission_tests::the_replacement_admission_sums_every_held_plan_and_model`. -- Gate 3: dropped (root ruling below). -- Gate 4/5: `cargo test --locked -p capi` (incl. `resource_lifecycle`, `plan_swap_race`), - `-p control-plane --all-features`, `-p host-web --all-features`; `audit capi` (pcm_digest - `bf7dcedd1d92621f`, 0 violations; `tools/audit/src/capi.rs` unchanged); `check-capi-abi.sh` and - `--self-test`; `check-workspace-policy.sh`; `realtime_policy`; fmt; clippy on the three crates: - all pass. - -**Measured.** Nine-track EQ reference session `capi_retained_bytes` 159,354 -> 161,258 (+1,904, -#1398 D1's tables). No test pinned that row; no browser pin moved. - -Root ruling (2026-10-15): gate 3 and `AdmissionPeak::WithReprepare` are dropped from this slice: code with no production caller is a placeholder. #1403 adds the reprepare peak together with its first caller. - -**Review fold (batch 15a).** -- *M2 (D4 "a carried ring counted once").* `validate_replacement_peak` subtracted only the new - candidate's carried bytes, while a held candidate's row also holds rings it carried from the - running plan. Each provider epoch now keeps its `CarriedSourceBytes`, `LiveEpochResources` - carries it, and the source-total and source-overhead sums (and so the replacement-peak term) - subtract it for every held epoch after the running one. Gate 2 adds a middle row carrying 13 - source bytes (5 overhead) and a running row carrying 2 (1): each cap passes at the peak and - refuses one byte below; red when every held row's carried bytes are left in. Measured on the - nine-track EQ reference session (T1 adds a source track, T2 a compressor; P0 12,198, A 23,282 - of which 10,640 carried): the smallest admitting `maximum_source_total_bytes` moves 48,122 -> - 37,482, the three-plan sum with each carried ring once (12,198 + 12,642 + 12,642). -- *M1 (with #1310).* The D3 accounting fault could fire after two `Taken` outcomes in a race; a - structural edit now promotes every pending candidate up to a taken newest one before it rebuilds, - so the report table and the pending list never meet more than D1 sizes (see #1310's fold). -- *Item 2.* A refusal at the three-plan peak is `BACKPRESSURE` when the edit fits beside the newest - held plan alone; `COMPILE_REJECTED` only otherwise (see #1310's fold). The header's sizing - paragraph says so, and says the rule is measured from a running successor plan's row. -- *m5.* The `held_epochs` doc no longer claims a displaced plan is reclaimed before any admission: - it is not charged, and between render's swap and the next service step it lives beside the held - plans uncharged. -- *m6.* `capi_retained_bytes` on the reference session is 165,842 at the batch 15a head and 165,922 - after this fold (x86-64; +80, which matches the 16-byte carried field in five provider-epoch - slots; measured with part B's uncommitted host-core and source edits in the tree), within - the 175,296 ceiling of `resource_lifecycle.rs`; the 161,258 above was not the head figure. - `C_ABI_V1_QUALIFICATION.md` states both and no longer says #1310's boot row "does not move" or - that a full plan queue is backpressure (it is `INTERNAL` since D3). The four-lane - `builtin_bank_bytes` comment cites AArch64 CI job 116713280504 (`12969 of 21056`). diff --git a/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md b/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md deleted file mode 100644 index a069991f2..000000000 --- a/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md +++ /dev/null @@ -1,350 +0,0 @@ -# Build the timeline consumer and producer in the source crate - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answer A1 (A1.2, A1.3), under decision 15 D15-16 -(`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`). A draft for root -to file. Code anchors verified on `6ee64f484`. Batch R1. - -## Product outcome - -`crates/source` gains the session's playhead as two types: a **timeline consumer**, a source -consumer with no PCM and no ring, and a **timeline producer**. The consumer starts at generation -1, timeline sample 0, advances one quantum on every block, and takes plain and anchored seeks -through the same seek state machine a source uses, now factored so that no rule is written twice. -It keeps a short history, so a reader can ask "what was the timeline at this source-read sample?" -for any sample a node of the plan can still be processing. - -This slice is crate-local. #1641 *Give every plan a timeline that carries like a source* puts -the consumer in every host-core plan and carries it; #1647 is its first render reader. Both are -in batch R1, so `main` never holds the types with no caller. - -## Context - -- **Every seek names one source.** `SourceCommand::{Seek, SeekAt}` - (`crates/source/src/lib.rs:51-78`). The producer checks generation zero, a strictly increasing - generation and an anchor that is a quantum multiple, then pushes into a one-slot command queue - and switches its own generation (`try_seek`, `:753-785`; anchor check `:771-775`; push `:776-784`). - A full slot is `SourceSeekError::Backpressure` and changes nothing (`:783`). -- **The consumer's seek state machine.** The consumer holds `active_generation` (`:1001`) and an - optional `HeldSeek` (`held_seek`, `:1018`; struct `:1021-1027`). `observe_seek_at_block_boundary` - (`:1323-1365`) pops one command: a plain seek clears any held seek and applies at once; an - anchored seek replaces the held one; a held seek applies in the first block whose sample is at - or past its anchor, at `frame + lateness` (`:1350-1364`). The source part of the same function - discards a pending block of a replaced generation (`:1340-1346`); `apply_seek` repositions the - PCM (`:1367-1386`). -- **Underrun still advances.** A block with no playable PCM advances `next_frame` by one quantum - (`:1154-1168`). The timeline advances the same way on every block. -- **Source bytes.** The ring's one-slot command queue is a reported row - (`SourceResourceReport::command_queue_bytes`, `crates/source/src/lib.rs:221-222`). The source - set's overhead is summed in `prepare_graph_source_set` (`:1904-1936`). -- **Sources have no offset.** Every region is `0..frames` - (`crates/host-core/src/prepare.rs:1231-1253`), so timeline sample `t` is source frame `t` when - the sources play in step (README A1.2). - -## Decisions frozen for this slice - -- **D1. One seek state machine, factored.** `crates/source` gains a private seek-state type that - owns `active_generation` and the held anchored seek, and one function that observes one popped - command against a `SeekClock` and returns the seek to apply now, if any: generation and frame, - lateness added. The source consumer and the timeline consumer both call it. Source-only work - (discarding a pending PCM block of a replaced generation, `apply_seek`) stays in the source - consumer and runs on the result. The producer side is factored the same way: one seek-producer - type owns the command producer and the active generation, with `check_seek(command)` (every - `try_seek` rule except the push) and `try_seek(command)` (`check_seek`, then the push). The PCM - producer and the timeline producer both hold one. No rule is written twice. -- **D2. The timeline consumer.** `TimelineConsumer`: the D1 seek state, a command consumer (the - one-slot queue a ring uses), `position: u64` (the timeline sample of the next block) and the - history (D3). No PCM, no data queue, no recycle queue. - - At construction it is generation 1, position 0. - - `begin_block_at(source_read_sample)`: observe one command (D1); a seek to apply sets - `position` to its frame; record `(source_read_sample, position)` in the history; then - `position += quantum`. It runs on every block, silent or underrun, so the timeline never - stops. Allocation-free, lock-free, bounded. -- **D3. History.** A fixed ring of `K` entries of 16 bytes, - `(source_read_sample: u64, timeline: u64)`, one per block, newest last. `K` is a constructor - argument (#1641 sets `K = ⌈A_max / q⌉ + 1`). - - Reader `timeline_at(source_read_sample) -> i64`: search the entries newest first for the - block that contains the sample (`entry.source_read_sample <= s < entry.source_read_sample + q`) - and return `entry.timeline + (s - entry.source_read_sample)`. A sample older than the oldest - entry extrapolates back from the oldest entry: `oldest.timeline - (oldest.source_read_sample - - s)`, which is negative before a fresh plan's first block. So a fresh plan gives node time - `-a(n)` at render sample 0 (README A1.4, "New lanes"). - - Reader `timeline_block(first_source_read_sample, q) -> TimelineSpan`: the node-time mapping of - one node block: `start: i64` and an optional step `(offset: u32, timeline: i64)` at the first - sample of a history entry whose timeline is not its previous entry's `+ q` (a seek). A node - block covers at most two source-read blocks, so it has at most one step. The step may fall at - the node block's first sample, when the node block starts exactly where the seek's source-read - block starts: the reader then reports `step = (0, t)`, `t` the seek's timeline value, and - `start` equal to `t`. So a seek that reaches a node block always yields a step, at any offset - from 0 to `q - 1`, and a reader never has to compare `start` with the previous block's end to - find a seek. #1647 copies it into #1637's `NodeSpan`. - - `copy_history_from(&other)`: copies the newest `min(K_other, K_self)` entries, oldest first, a - bounded copy with no allocation (#1641's carry). - - Every value is an exact integer. No floating point. -- **D4. The timeline producer.** `TimelineProducer`, the D1 seek producer plus nothing else. Plain - and anchored seeks, generation strictly above the current one, anchor a quantum multiple, one - slot: a full slot returns `SourceSeekError::Backpressure` and changes nothing. It exposes - `check_seek` (D1), so an all-or-nothing caller (#1642) checks every consumer before it pushes - any. A seek frame above `TIMELINE_SAMPLE_LIMIT = 2^53` is refused with a new - `SourceSeekError::FrameOutOfRange`. The limit is above any session's length (2^53 samples is - about 2,970 years at 96 kHz) and leaves `2^63 - 2^53` samples of headroom, so every timeline - value and every node time derived from it stays an exact `i64` for any run a host can render. -- **D5. Bytes.** `TimelineResourceReport` states the timeline's fixed bytes row by row: the one-slot - command queue (the ring's `command_queue_bytes` formula), the history (`16·K`) and the consumer. - #1641 adds them to the source set's overhead. -- **D6. The acked-batch question.** The timeline's command queue is a source's one-slot queue: a - seek that does not fit is refused with backpressure before anything changes, and a held seek is - never dropped. No ack can precede a drop. - -## Deliverables - -1. D1-D5 in `crates/source/src/lib.rs`, with unit tests. - -## Authorized paths - -- `crates/source/src/lib.rs`, `crates/source/tests/` -- `crates/host-core/src/source.rs` (the `FrameOutOfRange` arm of `SourceControlError::diagnostic`, - `:78-104`, only: that match has no wildcard and `SourceSeekError` is not `#[non_exhaustive]`, - `crates/source/src/lib.rs:381-393`, so host-core does not compile without it) and - `crates/host-core/tests/source_diagnostics.rs` (its `variant_index` and `TABLE`, `:27`, - `:176-198`, gain the new row) - -## Non-goals - -- Putting the consumer in a plan, its carry, its producer in `SourceControlSet` and the diagnostic - name (#1641). The C call (#1642), the browser export and SDK `seek` (#1643 and #1644), - and any automation reader (#1637, #1646 and #1647). - -## Hazards - -- **Hot file.** `crates/source/src/lib.rs` is stream B's (#1316, #1318, #1320). Their source slices - merge first (`docs/handoffs/decision-15-2026-10-05/STREAMS.md`); this slice rebases onto them and - factors their seek code, never a copy of it. - -## Objective gates - -1. **Seek state machine** (`crates/source/src/lib.rs` unit tests, new). On a `TimelineConsumer` - at quantum 128: - - a plain seek to `(2, 10_000)` reads 10,000 at the next block's first sample; - - an anchored seek `(2, F, A)` is held while the block sample is below `A` (the timeline keeps - advancing) and applies at `A` with frame `F`; observed late at `A + 256`, it applies at - `F + 256`; - - a newer seek, plain or anchored, replaces a held one; - - a stale or zero generation and an unaligned anchor are refused by the producer, which keeps - its generation; a second seek before the consumer pops the first is `Backpressure` and - changes nothing; a frame above `2^53` is `FrameOutOfRange`; - - the timeline advances by 128 on every block; - - `timeline_at` reads the right value on both sides of a seek inside its window and - extrapolates below the oldest entry; `timeline_block` returns the step offset for a node - block that straddles the seek's block, and `step = (0, t)` for a node block that starts - exactly at the seek's block; - - `copy_history_from` from a larger and from a smaller `K` keeps the newest entries in order. -2. **Shared rules.** The existing seek tests of the source consumer - (`a_newer_anchored_seek_replaces_a_held_one`, `a_primed_block_waits_for_its_anchor`, - `prepare_seek_holds_an_anchored_seek`, `an_unaligned_anchor_is_refused_without_a_generation_switch`, - `crates/source/src/lib.rs:2112-2218`) and `crates/source/tests/seek_schedule_model.rs` pass - unchanged. -3. **No rendered bit moves.** No render arithmetic changes; every existing render test passes with - unchanged digests. -4. **Commands:** - - `cargo test --locked -p source` - - `bash scripts/check-realtime-policy.sh`, `bash scripts/check-cross-targets.sh` (README F19, the iOS memset rule), - `bash scripts/check-workspace-policy.sh` - - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `cargo fmt --all -- --check` - -## Test value - -- Gate 1 turns red if the timeline applies an anchored seek without its lateness, lets a held seek - survive a newer command, reads the wrong side of a seek in its history, reports no step for a - seek at a node block's first sample, or copies the wrong end of a history. Nothing tests a - consumer with no PCM today. -- Gate 2 turns red if the factoring changes a source's seek behaviour. - -## Dependencies - -- Ordering only: stream B's `crates/source` slices, *Anchor every seek on the plan's source-read - clock* (#1316), *Report held source blocks apart from underruns* (#1318) and *Let a source - consumer check and replay its next blocks for a prime* (#1320), merge first: D1 factors their - seek code (Hazards). -- Batch: R1. #1641 depends on this draft. - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **Realtime policy.** `scripts/check-realtime-policy.sh` is deleted; the check is - `cargo build --locked --release -p realtime-policy && ./target/release/realtime_policy`, and its - floors are `marked_files_floor` and `marked_regions_floor` in `tools/realtime-policy/src/policy.rs:161-222`. -- **Dependencies.** #1316, #1318 and #1320 are still open; the ordering rule stands. The source - crate's seek anchors have moved; re-read them at filing. -- No decision 17 change: the timeline is source transport, not a control. - -## Amendment (controls coordinator, batch 16 check, 2026-10-16) - -Checked against `35ac17545`. - -- **Landed deps.** #1316, #1318 and #1320 are closed and on `main`. The ordering rule is met. -- **Moved anchors (`crates/source/src/lib.rs`).** `SourceCommand` `:56-87`. `SourceSeekError` - `:394-405` (still not `#[non_exhaustive]`). `PcmSourceProducer` `:819-835`; `try_seek` - `:888-919` (anchor check `:905-910`, push `:911-918`). `HostChunkProvider::try_seek` `:1039-1041` - forwards to it. `PcmSourceConsumer` `:1154-1194`; `held_seek` `:1181`; `HeldSeek` `:1199-1205`; - `SeekClock` `:1208-1222`. `observe_seek_at_block_boundary` `:1591-1633`; `apply_seek` - `:1638-1654`. Underrun advance: `begin_block_play` `:1372-1404`. `prepare_graph_source_set` - `:2421-2497`. Gate 2's four tests are at `:2654-2770`. -- **`SeekClock` has three cases now:** `Now`, `At { source_read_sample }` and `Hold` (#1320 - preparation and prime). D1's shared function takes all three. -- **The source observes a command at two points.** `begin_block_observe` and the acquire's one - re-observe (`acquire_current_block`, `:1772-1838`, #1274 MINOR-3). Both call the D1 function. The - timeline has no acquire, so it observes once per block. -- **The seek state is read outside the observe step too:** `settle_block`, - `is_unobserved_generation`, `prime_ready` and the held/underrun split in `begin_block_play`. The - D1 type gives read access to the held seek; no rule moves out of it. -- **Producer factoring.** `try_seek`'s post-push work (`next_write_frame`, `end_of_region_submitted`) - is PCM-only and stays in `PcmSourceProducer`. The shared seek producer needs the quantum for the - anchor check. -- **`check_seek` on the PCM side (for #1642 D1 step 2).** `PcmSourceProducer` and - `HostChunkProvider` (the type `SourceControlSet` holds) both expose `check_seek(command)`, - forwarding to the shared seek producer, as `try_seek` already does. -- **The seek report (#1316) is per ring, not a table.** One seqlock record - (`PublishedWordsWriter`, 5 words, `crates/engine/src/realtime/seqlock.rs`) - per ring. The consumer publishes it (`publish_seek_report`, `:1575-1589`) and the producer reads - it (`seek_report`, `:924-929`). If #1641 D6 stands, the timeline gets the same record through - the same writer and reader, unchanged. D5 then also needs a seek-report row - (`published_words_retained_bytes::<5>()`, as `SourceResourceReport::seek_report_bytes`). -- **Diagnostic name.** The Non-goals say "the diagnostic name (#1641)". #1641 D2 overrides that: - this slice adds the arm as `source.seek.frame_out_of_range`. Host-core anchors moved: - `SourceControlError::diagnostic` `crates/host-core/src/source.rs:86-118`; - `crates/host-core/tests/source_diagnostics.rs` `TABLE` `:27`, `variant_index` `:182-205` - (the new row is index 20). No other crate matches on `SourceSeekError`. -- **No `crates/engine/src/realtime/` edit.** The SPSC and seqlock types are used as they are. -- **Decision 17.** No change. C13 (ruling 2026-10-15) calls seeks and the seek report transport. - -## Decision record: #1640 (batch 16) - -Built on `eab696d09` (worktree `codex/controls-batch-16a`). - -**What shipped** (`crates/source/src/lib.rs`): - -- **D1.** `SeekState` (owns `active_generation` and `held_seek`) with one `observe(command, - clock)` over all three `SeekClock` cases, plus the shared reads `holds_anchor_before`, - `holds_generation` and `is_unobserved`. The PCM consumer calls `observe` at both of its points - (`begin_block_observe` and the acquire's re-observe, both through - `observe_seek_at_block_boundary`) and runs only source work on the result: the stale pending - block discard on a newly held seek, and `apply_seek`. `prime_ready`, `settle_block` and - `is_unobserved_generation` read the held seek through the shared methods, so B16-R3's readiness - rule exists once for #1641. `SeekProducer` (command producer, generation, quantum, optional frame - limit) has `check_seek` and `try_seek`. `PcmSourceProducer::try_seek` is the shared `try_seek` - plus its PCM-only work (`next_write_frame`, `end_of_region_submitted`). - `PcmSourceProducer::check_seek` and `HostChunkProvider::check_seek` forward to the shared check. -- **D2-D3.** `TimelineConsumer` (`begin_block_at`, `timeline_at`, `timeline_block`, - `copy_history_from`, `position`, `active_generation`, `history_capacity`), with history entries - `(u64, u64)` in a fixed `K`-entry ring. `SourceTimeline::prepare(quantum, K)` builds it at - generation 1, position 0. `TimelineSpan { start, step: Option }`. -- **D4.** `TimelineProducer` (`try_seek`, `check_seek`, `active_generation`), - `TIMELINE_SAMPLE_LIMIT = 1 << 53`, `SourceSeekError::FrameOutOfRange`. -- **D5.** `TimelineResourceReport`: `history_blocks`, `command_queue_bytes` (the ring formula), - `command_queue_largest_allocation_bytes` (#1641 D7) and `history_bytes` (`16·K`). No - seek-report row (B16-R4). The batch 16a fold removed `consumer_bytes`, `overhead_bytes` and - `largest_allocation_bytes` (see the fold paragraph). -- **Host-core.** The `FrameOutOfRange` arm, `source.seek.frame_out_of_range`, and its row 20 in - `crates/host-core/tests/source_diagnostics.rs`. - -**Choices the text left open:** - -- `check_seek` reports a full slot as `Backpressure` with the current `full_count`, through - `available_capacity`. It does not count a refusal. `try_seek` still gets backpressure from its own - push, so the PCM producer's full count moves exactly as before. -- The frame limit applies to the timeline only (`frame_limit: None` for a PCM source). The rule is - written once in the shared check. -- A seek in the history's oldest entry is still a step: the consumer keeps one `u64`, - `oldest_expected_timeline`, which is the evicted entry's timeline plus `q`, or the starting - position. `copy_history_from` carries it. -- `timeline_at` with no entry treats the position as standing at source-read sample 0. A sample in - a gap or after the newest entry extrapolates forward from the newest entry that starts at or - before it. A step is reported at the first sample `s` of the node block where - `timeline_at(s) != timeline_at(s - 1) + 1` (a seek, or a source-read step-back's overlap; fold - M1), so for a node block with at most one step, which is every block without a step-back inside - it, the step and `timeline_at` agree on every sample. Of two steps, the first is reported. -- `timeline_block(first, frames)` takes the node block's length as `frames`. - -**Departures:** - -- Gate 2 "unchanged": the assertions are unchanged. Two of the four named tests, seven others and - the shared `twin` helper read the moved fields, so they now read `consumer.seek.active_generation` and - `consumer.seek.held_seek` (a path rewrite only). `crates/source/tests/seek_schedule_model.rs` is - textually unchanged. `crates/source/tests/randomized.rs` gains one `unreachable!` arm for the new - variant (exhaustive match). -- `tools/realtime-policy/src/policy.rs`: `marked_regions_floor` 151 -> 153, for the two new regions - (mechanical, outside the authorized paths). - -**For #1641:** the consumer value is inline. #1641 stores it inside an allocation it already -counts (the driver), so no row charges it (fold MINOR 6 removed `consumer_bytes`). - -**Gates:** - -- Gate 1, `cargo test --locked -p source`, `tests::timeline::*` (11 new; the fold replaced the - first two, see below): - `an_anchored_seek_is_held_until_its_anchor_and_applies_with_its_lateness`, - `a_newer_seek_plain_or_anchored_replaces_a_held_one`, - `the_producer_refuses_without_changing_anything`, - `the_history_reads_both_sides_of_a_seek_and_extrapolates_below_its_oldest_entry`, - `a_node_block_reports_the_step_of_a_seek_inside_it_or_at_its_first_sample`, - `a_seek_in_the_oldest_entry_is_still_a_step`, - `copying_a_history_keeps_its_newest_entries_in_order`, - `the_resource_report_states_each_row` and - `a_source_check_seek_agrees_with_its_try_seek_and_pushes_nothing`. PASS. -- Gate 2: all 68 lib tests (the four named seek tests among them), `seek_schedule_model`, - `randomized` and `prime`. PASS. -- Gate 3: no render arithmetic changed. The source tests, plus host-core `successor_swap` (14) - and `prepare` (139), PASS. -- Commands, all PASS: `realtime_policy` (153 regions in 33 files), `check-cross-targets.sh`, - `check-workspace-policy.sh`, `cargo test -p realtime-policy`, `source_diagnostics` (2), - clippy `-p source -p host-core --all-targets --all-features -D warnings`, and - `cargo fmt --all -- --check`. The workspace-wide clippy was not run (worker rules). - -**Fold (batch 16a review).** - -- **M1.** `timeline_block` reported a step only at a seek entry, so a node block that straddles a - source-read step-back (#1641 D4) read part of its samples a whole overlap off (`q = 128`, blocks - at 0, 128, 256, 384, 256: `timeline_block(216, 128)` gave no step while `timeline_at(256)` is - 512). It now checks every entry's first sample and its end (`sr + q`) inside the node block and - reports the first sample `s` where `timeline_at(s) != timeline_at(s - 1) + 1`. Where no held entry - starts at or before `s - 1` (or `s` is 0), the entry at `s` is a step when it is a seek, read - against `oldest_expected_timeline` as before. Bound: at most `6K² + 6K` entry reads (fold 2: - `6K² + 10K + 1`), allocation-free. Test - `a_node_block_reports_the_steps_of_a_source_read_step_back` (red on the unfixed code: - `{start: 216, step: None}`), which also checks every block's span against `timeline_at` sample by - sample. -- **MINOR 4.** `the_reader_reads_the_newest_overlap_and_reports_the_first_step` (`K` 4 and 3): red - for a `resolve` searched oldest first, a later step reported instead of the first, and an - eviction that does not update `oldest_expected_timeline` (each mutation run). -- **MINOR 5.** D6 and `TimelineProducer::try_seek` now read: an accepted seek applies unless a newer - accepted seek replaces it first (#1274); it is never dropped silently. -- **MINOR 6.** `TimelineResourceReport` drops `consumer_bytes`, `overhead_bytes` and - `largest_allocation_bytes`: nothing read them after #1641, and the two sums counted the inline - consumer, which no allocation of the timeline holds. No charged byte moved (the source set never - read them). -- **MINOR 7.** `offset_timeline` is inside the marked region (its END marker moved below it; 153 - regions, unchanged). -- **Test value.** `a_fresh_timeline_starts_at_zero_and_advances_a_quantum_every_block` and - `a_plain_seek_reads_its_frame_at_the_next_blocks_first_sample` are deleted (no unique catch: the - anchored, newer-seek and history tests cover both). `the_resource_report_states_each_row` lost - its `consumer_bytes` check with the row. New: `a_vacant_timeline_never_adopted_advances_from_zero` - (#1641's, in this module). -- Rustdoc: the public docs no longer link the private `SeekState`, `SeekClock::At` and - `SeekState::prime_window_clear` (`cargo doc -D warnings`). -- **Fold 2: M1 completed.** After a step-back the oldest held entry is not the lowest source-read - sample, so where no held entry starts at or before `s - 1` the value at `s - 1` is extrapolated - back from an entry that need not start at `s`, and the seek-only test missed a real step (`K` 3, - blocks at 0, 128, 256, 384, 384, 512: `timeline_block(260, 128)` gave no step, `timeline_at(384)` - is 512; `K` 2, blocks at 0, 128, 256, 384, 256: `timeline_block(200, 128)` gave no step, - `timeline_at(256)` is 512). `step_at` now reports a step wherever `s > 0` and - `timeline_at(s - 1) + 1 != timeline_at(s)`, and keeps the oldest-seek rule where no held entry - starts at or before `s - 1`, so the claim above (a node block with at most one step agrees with - `timeline_at` on every sample) holds. Bound: at most `6K² + 10K + 1` entry reads, - allocation-free, no new panic path, inside its `REALTIME_POLICY` region. Tests - `a_step_back_steps_where_no_held_entry_starts_before_it` (the two cases) and - `every_node_block_with_one_step_agrees_with_timeline_at_after_a_step_back` (`K` 1 to 5, step-backs - of 64, 128, 200, 256 and 384, a node block from every fourth sample): both red on the fold-1 code - (`{start: 260, step: None}`; the sweep reads 384 for 512), green with the fix. diff --git a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md deleted file mode 100644 index 217f4103b..000000000 --- a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md +++ /dev/null @@ -1,461 +0,0 @@ -# Give every plan a timeline that carries like a source - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answer A1 (A1.2, A1.3), under decision 15 D15-16 -(`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`). A draft for root -to file. Code anchors verified on `6ee64f484`. Batch R1. - -## Product outcome - -Every prepared plan, on both hosts, carries one timeline consumer (#1640): the session's -playhead. The graph's source driver advances it at every block entry, before the sources, from the -plan's source-read clock. A successor plan carries it, history included, exactly as it carries an -unchanged source: across a rebuild (#1273), a declared discontinuity (#1323) and a prime adoption -(#1320), and the seek report (#1316) has a row for it. Its producer sits in `SourceControlSet` and -moves with the persisting producers. Its fixed bytes are charged to the source overhead cap. - -Nothing renders from it yet. The C call that seeks it is #1642, the browser export and the SDK -#1643 and #1644, and the first reader #1647. All are in batch R1. - -## Context - -- **The graph's source driver.** `SourceGraphSourceSetDriver` (`crates/source/src/lib.rs:1611-1617`) - calls `consumer.begin_block_at(first_sample)` for each source at block entry - (`:1701-1736`, the call `:1720-1721`). The graph executor calls the driver from its block entry - (`crates/graph/src/lib.rs:3254-3256`). The source set is an `Option` on the executor - (`:3254`), but host-core always builds one (`crates/host-core/src/prepare.rs:1635-1636`), and - `prepare_graph_source_set` refuses an empty source list (`crates/source/src/lib.rs:1901-1903`), - so every host-core plan has a source set. -- **How an unchanged source carries (#1273).** Preparation finds the predecessor's inventory row, - allocates no ring and pushes a vacant entry (`crates/host-core/src/prepare.rs:1213-1240`). The - carry program lists `(successor, predecessor)` source pairs (`crates/graph/src/lib.rs:2750-2771`) - and is built only when something carries (`crates/host-core/src/prepare.rs:1294-1302`). At the - swap block `adopt_predecessor` (`crates/graph/src/lib.rs:3139-3177`) calls the driver's - `adopt_sources`, which swaps each consumer into its vacant entry with no allocation - (`crates/source/src/lib.rs:1845-1888`). The host moves the producers first: - `SourceControlSet::adopt_persisting` (`crates/host-core/src/source.rs:273-301`); the C ABI calls - it at commit (`crates/capi/src/runtime/control.rs:1019`). `SourceControlSet` is the per-session - producer table (`crates/host-core/src/source.rs:127-130`); its diagnostics are - `SourceControlError::diagnostic` (`:78-103`). -- **Source bytes.** host-core checks the source set's overhead, plus the carried rings' overhead, - against `maximum_source_overhead_bytes` (`crates/host-core/src/prepare.rs:1640-1652`). -- **Node arrival.** PDC computes each node's input arrival `max` in `timings` - (`crates/graph-compiler/src/pdc.rs:53-104`). *Keep every node's latency from dropping during - playback* (#1285) D2 records each node's floored `max` in the plan inventory. `A_max`, the - largest of them, sizes the history (#1640 D3). -- **The mechanisms the timeline joins**, each filed and a dependency of this slice: - - *Anchor every seek on the plan's source-read clock* (#1316): the per-source seek report; - - *Give a plan a source-read clock that leads its render clock* (#1396): the sample the driver - passes at block entry, and its step back at a discontinuity (#1396 D3); - - *Reset latency floors at a host-declared discontinuity* (#1323): `SuccessorBase::discontinuity` - and the carry of each consumer at it (#1323 D2-D3); - - *Let a source consumer check and replay its next blocks for a prime* (#1320): the readiness - check and the prime that a warm adoption runs on each carried consumer. -- **Readers of the plan.** A test reaches the executor through - `PreparedRenderPlan::executor_any_mut` (`crates/engine/src/realtime/plan.rs:912`); graph exposes - plan facts through free functions that downcast, for example `carry_program_retained_bytes` - (`crates/graph/src/lib.rs:2911-2927`). - -## Decisions frozen for this slice - -- **D1. In every plan.** Host-core preparation builds one timeline consumer in every source set, - with `K = ⌈A_max / q⌉ + 1` (`q` the plan's quantum, `A_max` the largest floored arrival of - #1285 D2, 0 for a plan with no latency, so `K = 1`). The driver calls its `begin_block_at` at - block entry, before the sources, with the source-read sample it passes the sources (#1396). -- **D2. The producer.** `SourceControlSet` owns one `TimelineProducer` beside the source producers, - and `adopt_persisting` moves it like a persisting source's producer. `FrameOutOfRange` maps to - `source.seek.frame_out_of_range` in `SourceControlError::diagnostic`; #1640 adds that arm, - because host-core does not compile without it. Host-core gains no public - timeline seek here; #1642 adds the session seek. -- **D3. Carry on a rebuild.** A successor carries the timeline exactly as an unchanged source, on - every successor (it never changes): - - preparation builds a vacant timeline entry that owns its own history of its own `K`, and no - command queue; - - the carry program gains `timeline: bool`, set on every successor, so a program exists even - when no source or input lane carries; - - at the swap block the driver's `adopt_sources` swaps the seek state, the command consumer and - `position` into the vacant entry and calls `copy_history_from` (#1640 D3); nothing is - allocated or freed; - - a vacant timeline that is never adopted (a carry mismatch, unreachable on both hosts by - #1269 P11) advances from 0 with no command queue, so render stays defined. - - **Why the successor's own window is enough.** The successor reads the history only for its own - nodes at its own arrivals. Floors (#1285) never lower a carried node's arrival, and a warm - adoption raises both a carried node's floor and the source-read clock by `P` (README A1.3), so a - carried node never reads further back than it did on the predecessor, and the predecessor's - `K` covered that. Only a node that the successor adds, restarts or grows can read further - back. Such a node starts at rest at adoption (#1324, #1397), so the samples it reads before its - first input arrives label rest state, and the extrapolation of #1640 D3 gives them a - defined, host-independent value that is exact unless a seek fell in the missing span. From the - successor's `⌈A_max / q⌉`-th block on, its own entries fill its window. -- **D4. Carry at a declared discontinuity** (#1323). A discontinuity successor carries the timeline - as D3 does. Its source-read clock steps back to the render clock (#1396 D3), so the history can - hold two entries for one source-read sample; #1640's reader searches newest first, which is - the entry render played last. The nodes that read the overlap start at rest (#1323 D2), so no - continuity is owed. -- **D5. Prime** (#1320). The timeline answers #1320's readiness check as always ready (it has no - PCM to run out of), and its prime advances it by `k` blocks, recording `k` history entries, as - `k` calls of `begin_block_at` that observe no command. So a warm adoption (#1355) moves the - timeline exactly as it moves the sources' frames. -- **D6. The seek report** (#1316). The report gains one row for the timeline, of the same form as - a source's row (generation in effect, the source-read sample of the first block it played in, - the timeline value that block started at); the underrun and held counts are 0. -- **D7. Bytes.** `prepare_graph_source_set` adds #1640's `TimelineResourceReport` to the source - set's `overhead_bytes` and `largest_allocation_bytes`, so the source overhead cap bounds them on - both hosts. A vacant (carried) timeline charges only its history; its carried command queue is - counted with the carried source overhead, as a carried ring's is - (`crates/host-core/src/prepare.rs:1220-1230`). -- **D8. Test reader.** `graph` cannot name a `crates/source` type (`source` depends on `graph`), - so the source-set driver trait (`crates/graph/src/lib.rs:2161-2232`) gains one defaulted method, - `timeline_at(&self, source_read_sample: u64) -> Option` (`None` by default), which the source - driver implements with #1640's reader. `graph::test_only_timeline_at(plan: &mut - PreparedRenderPlan, source_read_sample: u64) -> Option` (`test-support`) downcasts as - `carry_program_retained_bytes` does and calls it. The render-side block reader comes with - #1647. -- **D9. The acked-batch question.** A held seek moves with its consumer across every carry; no - ack can precede a drop. - -## Deliverables - -1. D1, D3-D5 and D7 in `crates/host-core/src/prepare.rs` and in the source driver - (`crates/source/src/lib.rs`). -2. D2 in `crates/host-core/src/source.rs`; D6 in the seek report; D8 and the carry flag in - `crates/graph/src/lib.rs`. -3. Host-core tests in `crates/host-core/tests/timeline.rs` (new) and one case in - `crates/host-core/tests/successor_swap.rs`. - -## Authorized paths - -- `crates/source/src/lib.rs` (the driver, the vacant entry, the prime and the report row only) -- `crates/host-core/src/prepare.rs`, `crates/host-core/src/source.rs`, - `crates/host-core/tests/timeline.rs` (new), `crates/host-core/tests/successor_swap.rs` -- `crates/graph/src/lib.rs`: `GraphCarryProgram`'s `timeline` field, the `adopt_predecessor` - condition that calls `adopt_sources`, the driver trait's `timeline_at` and D8's test reader only -- the seek report's files as #1316 leaves them (the timeline row only) -- Tests that pin a source overhead or carry-program byte count that D7 or D3 moves (re-pin each - with its reason, never in bulk) -- `crates/host-core/src/lib.rs` (a `test-support` re-export of `graph::test_only_timeline_at` - only: host-web has no `graph` dependency, `hosts/host-web/Cargo.toml:25-33`, so its tests read - the timeline through host-core) and `crates/host-core/Cargo.toml` (its `test-support` feature, - `:16`, gains `graph/test-support`), `Cargo.lock` -- `hosts/host-web/tests/browser-v1/expected.json` (`resourceCeilings`) and - `hosts/host-web/tests/retained_ceilings.rs`, only if D7's bytes cross the browser's - `sourceOverheadBytes` ceiling (re-pin with the reason) - -## Non-goals - -- The C call (#1642), the browser export and SDK `seek` (#1643 and #1644), and any automation - reader (#1637, #1646 and #1647). -- The protocol's transport state, which never reports the timeline (#1635 retires its stored - position). -- A plan with no source set. Every host-core plan has one; the refusal of an empty source list - (`crates/source/src/lib.rs:1901-1903`) is unchanged. - -## Hazards - -- **Order of the block entry.** The timeline must observe its command in the same block as a - source given the same command. Both run in the one driver call at block entry; D1 puts the - timeline first and applies no state to sources. -- **Byte rows move.** Every source overhead row grows by the timeline's bytes. Each pinned number - that moves is re-pinned with the reason "the timeline consumer's fixed bytes (#1641 D7)". -- **Hot file.** `crates/host-core/src/prepare.rs` follows the merge order in STREAMS.md; this slice - lands after every slice it depends on. - -## Objective gates - -1. **Source and timeline agree** (`crates/host-core/tests/timeline.rs`, new). A one-track session - at 48 kHz, quantum 128, a playing source. Push the same seek, `(g, F, A)` anchored and then - `(g + 1, F2)` plain, to the source and to the timeline (through `TimelineProducer` directly). - For ten blocks after each, the frame each block's source read starts at equals - `test_only_timeline_at` of that block's sample, and the seek report's timeline row equals the - source's row in generation, sample and frame. -2. **Carried across a rebuild** (same file). Render 20 blocks, seek the timeline, render 5, then - prepare and swap a successor (one added track). After the swap the timeline continues with no - gap or repeat for 10 blocks, and `test_only_timeline_at` returns the predecessor's values for - every sample in the successor's window. A successor with a latent insert (larger `A_max`) keeps - the newest entries and extrapolates the rest. The producer moves: a seek pushed through the - successor's `SourceControlSet` reaches the carried consumer. -3. **Carried at a discontinuity and a prime** (same file). A declared discontinuity followed by a - session of sources and timeline seeks keeps the two in step for ten blocks; a warm adoption that - primes `k = 2` blocks advances the timeline by `2q`, the same as the sources' frames. -4. **No allocation in render.** The swap block and the ten blocks after it allocate and free - nothing (`bench_support::alloc` thread counters after warming, as - `the_swap_block_allocates_and_frees_nothing`, `crates/host-core/tests/successor_swap.rs:476`). -5. **No rendered bit moves.** No render arithmetic changes. Every existing render test passes with - unchanged digests; `./target/release/audit capi` shows the same `pcm_digest` at base and head - (PR evidence) and 0 violations. -6. **Commands:** - - `cargo test --locked -p source` - - `cargo test --locked -p host-core --features host-core/test-support` - - `cargo test --locked -p graph --features graph/test-support` - - `cargo test --locked -p capi` - - `cargo build --locked --release -p audit -p capi && ./target/release/audit capi` - - `bash scripts/check-realtime-policy.sh`, `bash scripts/check-cross-targets.sh` (README F19, the iOS memset rule), - `bash scripts/check-host-core-policy.sh`, `bash scripts/check-workspace-policy.sh` - - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `cargo fmt --all -- --check` - -## Test value - -- Gate 1 turns red if the timeline observes a command one block before or after a source given the - same command (the fault that would put every automation curve one quantum off its audio), or if - the seek report's row disagrees with the sources. -- Gate 2 turns red if a successor restarts the timeline at 0, drops its history or its producer, or - sizes its history from the predecessor's `K`. -- Gate 3 turns red if a discontinuity or a prime moves the sources and not the timeline. -- Gate 4 turns red if the carry or the history write allocates on the render thread. - -## Dependencies - -- #1640 *Build the timeline consumer and producer in the source crate*. -- *Keep every node's latency from dropping during playback* (#1285): D2's recorded floored - arrivals give `A_max` (D1). -- *Anchor every seek on the plan's source-read clock* (#1316): the seek report (D6). -- *Reset latency floors at a host-declared discontinuity* (#1323): the discontinuity successor - (D4). -- *Give a plan a source-read clock that leads its render clock* (#1396): the sample at block entry - (D1, D4). -- *Let a source consumer check and replay its next blocks for a prime* (#1320): readiness and prime - (D5). -- Batch: R1, with #1647 *Render moving stored fader automation, seeks and latency*, its first - reader. #1642, #1643 and #1644 build on it. - -None of these specs is amended for the timeline: each lands before this slice, and this slice adds -the timeline's line to the mechanism each one built. - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **Moved files.** The C ABI's commit-time `adopt_persisting` call is in `crates/control-plane/src/control.rs` - (#1309); `crates/capi/src/runtime/control.rs` is gone. -- **The browser** runs the same `SessionState` (#1562a, single instance), so D1-D7 reach it by - construction; its successor (carry) path is refused `web.apply.rebuild_pending` until #1562b, so - the browser half of gate 2 waits for #1562b. -- **Realtime policy** as in #1640's amendment (`./target/release/realtime_policy`). -- **Dependencies.** #1285, #1316, #1320, #1323 and #1396 are still open. - -## Amendment (controls coordinator, batch 16 check, 2026-10-16) - -Checked against `35ac17545`. - -- **Landed deps.** #1285, #1309, #1316, #1318, #1320, #1323, #1396 and #1562 (with #1562b) are - closed and on `main`. #1355 (warm adoption) and #1647 are open. -- **The browser runs successors now** (#1562b-W, `2b79cec44`), through the same control plane, - each one counted as a blocking rebuild. The note "the browser half of gate 2 waits for #1562b" - is void. -- **Moved anchors, `crates/source/src/lib.rs`.** `SourceGraphSource` `:1978-2021` (`vacant` - `:2013`). `GraphSourceEntry` `:2041-2046`. Driver `SourceGraphSourceSetDriver` `:2048-2054`. - Its `begin_block` `:2138-2174` (the call `:2159`). `adopt_sources` `:2289-2325`. - `prepare_graph_source_set` `:2421-2497`. -- **Moved anchors, `crates/graph/src/lib.rs`.** Driver trait `:2565-2667`. `GraphCarryProgram` - `:3310-3324`. `install_carry_program` `:3699`. `carry_program_retained_bytes` `:4349`. - `adopt_predecessor` `:4728-4830`; it calls `adopt_sources` only `if !program.sources.is_empty()` - (`:4794-4803`), so D3's flag goes into that condition. Block entry: `:4964-4978`, which reads - `time.absolute_sample + source_read_offset` (#1396 D1). `PreparedRenderPlan::executor_any_mut` - is `crates/engine/src/realtime/plan.rs:1252`, `executor_any` `:1261`. -- **Moved anchors, `crates/host-core/src/prepare.rs`.** Source loop with the carry `:2234-2295` - (vacant push `:2273`, carried overhead `:2253-2263`). Region `0..frames`: `builder.push` - `:2264-2271` and `:2281-2288`. Carry program `:2441-2457`. Floored arrivals - `artifact.report().input_arrivals` `:2846-2851`. `prepare_graph_source_set` and the overhead cap - `:2916-2932`. Offset set `:3439-3440`. `at_discontinuity` `:1032-1056`. -- **Moved anchors, host-core and control plane.** `SourceControlSet` `crates/host-core/src/source.rs:137-140`, - `adopt_persisting` `:321-336`, `diagnostic` `:86-118`. The control plane calls `adopt_persisting` - at three places: `crates/control-plane/src/control.rs:2705` (across a withdrawn candidate), - `:2732` (an ordinary commit) and `:2816` (#1310 D5 step 5, the hand-back to P0). D2's timeline - producer moves at all three, with no new call. -- **`A_max` is readable in time.** `input_arrivals` (floored, nonzero rows only) exists before - `prepare_graph_source_set` is called. So K is `⌈max / q⌉ + 1`, or 1 when it is empty. The - inventory keeps the same rows (`PlanStateInventory::input_floors()`, `:1000`). -- **Stale: the empty-source refusal.** `prepare_graph_source_set` no longer refuses an empty source - list. Host-core still always builds a source set (`:2916`), so a session with no sources also - gets a timeline. The Non-goal "a plan with no source set" stands. -- **Discontinuity (#1323).** `at_discontinuity` keeps only the source rows and sets - `source_read_offset` to 0, so the step back is #1396 D3 as written. D4 holds. The carry program - exists at a discontinuity only when something carries; D3's flag makes it exist on every - successor. -- **Donation (#1344).** `swap_sources` and `donate_sources` (`graph` `:4431-4475`) exchange - *fresh* entries between two unpublished plans. Every control-plane candidate is a successor, so - its timeline is vacant and is never donated. The driver's `swap_sources` leaves the timeline - where it is. -- **D3 also moves the seek report.** If D6 stands, the adopt step also swaps the timeline's - seek-report writer and its last published copy, as a source's report moves with its consumer - (#1316 D5). -- **D6 is per ring, not a row in a table.** There is no shared report table. Each ring has a - 5-word seqlock record (`crates/engine/src/realtime/seqlock.rs`). Host-core reads it per source - ID (`SourceControlSet::seek_report`, `:258`); the C ABI reads it with - `miso_engine_v1_source_seek_report` (`crates/capi/src/ffi.rs:652`). The timeline's "row" is its - own record of the same words. #1642 lists it as a non-goal, so no host reads it after this - slice. -- **D5: no prime path exists above the consumer.** #1320 shipped `PcmSourceConsumer::prime_ready` - (`:1676`) and `prime_block_at` (`:1742`) and the `prime_required` flag. `GraphExecutor` does not - implement `PreparedPlanExecutor::prime_ready` (the default `false`, - `crates/engine/src/realtime/plan.rs:356`), and the driver has no prime call. #1355 adds them, - and its spec does not name the timeline yet. Until then, gate 3's prime half can only compare - the two consumers directly. -- **D7 byte pins (measured on `35ac17545`, x86-64).** `hosts/host-web/tests/retained_ceilings.rs` - `source_overhead_bytes` is 2,658 of 2,688, and - `crates/capi/tests/resource_lifecycle.rs` is 4,174 of 4,352. The timeline's bytes will very - likely cross both ceilings. The browser's wasm `sourceOverheadBytes` ceiling (2,496, - `hosts/host-web/tests/browser-v1/expected.json:73`) is likely to cross too. Re-pin each one with - the reason. Keep the identity `source_overhead + carried_source_overhead == fresh` - (`crates/host-core/tests/successor_swap.rs:197-200`). -- **Gate 1 hazard, plain seeks pushed while render runs.** One driver call cannot put a timeline - and a source in the same block on its own. The two queues are popped at different instants, so - a plain seek pushed during a block can land one block apart. Gate 1 pushes between blocks, so - it is deterministic. Only anchored seeks are exact under concurrency (#1642's concern). -- **Realtime policy.** The floors are now `tools/realtime-policy/src/policy.rs:228-229`. New - regions only add to them. -- **Decision 17.** No change. C13 (rulings 2026-10-15 and 2026-10-16) calls seeks and the seek - report transport. Exposing a timeline report to a host would need a new direct export or a - protocol message, and a new direct export needs its own root ruling. - -## Amendment (root rulings, batch 16, 2026-10-16) - -- **B16-R3, D5 readiness.** The timeline is not "always ready". Its readiness is the source - consumer's non-PCM test, written once (#1640 D1's shared seek state): its command queue is empty - and it holds no anchored seek whose anchor falls before the end of the prime window. So a prime - never skips a held anchored seek. Its prime still advances it by `k` blocks, as `k` calls of - `begin_block_at` that observe no command. #1355 calls the readiness check and the prime on the - timeline as on each carried consumer (amended there). Gate 3's prime half compares the timeline - and a source consumer directly until #1355 wires the prime above the consumer. -- **B16-R4, D6 deferred.** No host reads a timeline seek report after this batch. D6 (the - timeline's seek-report record), its bytes in D7 and its move in D3 are taken out of this slice. - #1704 *Report the timeline seek position to hosts* keeps them, for the first slice - that needs a host reader. Gate 1 compares `test_only_timeline_at` with the frame each block's - source read starts at; its seek-report clause is dropped. - -## Decision record: #1641 (batch 16) - -Built on `3f53c0755` (worktree `codex/controls-batch-16a`, #1640's timeline types). - -**What shipped:** - -- **D1** (`crates/source/src/lib.rs`, `crates/host-core/src/prepare.rs`). `prepare_graph_source_set` - takes a `TimelineConsumer` (a new third argument; refused with - `SourceGraphSourceSetError::TimelineQuantum` when its quantum is not the envelope's). The driver - holds it inline and calls its `begin_block_at(first_sample)` at block entry, after the quantum - check and before the sources, with the sample the sources get. Host-core sizes it - `K = ⌈A_max / q⌉ + 1` from `artifact.report().input_arrivals` (1 when empty), just before the - source set is sealed. -- **D2** (`crates/host-core/src/source.rs`). `SourceControlSet` holds `Option`: - a fresh plan's, set at preparation; `None` on a successor until `adopt_persisting` takes the - predecessor's by the same rule as a vacant source's producer. The return value still counts - source producers only (callers assert it). No public timeline seek; a `test-support` accessor - `test_only_timeline_producer` serves the gates. -- **D3.** `SourceTimeline::prepare_vacant(q, K)`: its own history, no command queue - (`command_consumer: Option<_>`). `GraphCarryProgram { timeline: bool }`; `adopt_predecessor` - calls `adopt_sources` when `!sources.is_empty() || timeline`. The driver moves the timeline when - its own is vacant and the predecessor's is occupied (any other pairing leaves both timelines as - they are and still moves the sources; fold M3), through `TimelineConsumer::adopt_from`, which - swaps seek state, command consumer and position and calls `copy_history_from`. Nothing is allocated or freed. A vacant timeline never adopted advances - from 0 and pops nothing. `swap_sources` (donation) leaves the timeline where it is. -- **D4.** No extra code: the discontinuity successor carries as D3 (its program now always exists). -- **D5 (B16-R3).** `TimelineConsumer::prime_ready(first, lead)` and `prime_block_at(sample)`. The - seek half of readiness is written once, `SeekState::prime_window_clear` (no queued command, no - held anchor before the window end); `PcmSourceConsumer::prime_ready` now calls it too. No - driver prime path (#1355). -- **D7.** The set's overhead gains the history (`16·K`) and, for an occupied timeline, the command - queue; its largest allocation the larger of the two (`TimelineResourceReport` gains - `command_queue_largest_allocation_bytes` for this). `consumer_bytes` is not charged again: the - consumer is inline in the driver, whose own allocation row counts it. A vacant timeline's queue - is added to `carried_source_total_bytes`, `carried_source_overhead_bytes` and the carried largest - allocation, so `source_overhead + carried_source_overhead == fresh` holds. -- **D8.** `GraphPreparedSourceSetDriver::timeline_at` (default `None`), `graph::test_only_timeline_at` - (test-support), re-exported by host-core under `test-support`, whose feature gains - `graph/test-support`. `Cargo.lock` did not change (features are not locked). - -**Departures:** - -- **The timeline flag needs the same rate and quantum.** `timeline_carries` is true for a successor - whose inventory rate and quantum equal the compiled session's. Both hosts refuse a change of - either (#1290a), so on both hosts it is every successor; a hand-prepared test successor at - another quantum gets a fresh timeline instead of a carry that would be refused at the swap and - take every other carry down with it. -- **The vacancy, not the flag, tells the driver to move the timeline**, so the trait's - `adopt_sources` signature is unchanged. The flag makes the call happen. -- **The `has_carry_program` fallback** in `prepare.rs` (a program for delay/route carries) is kept - with `timeline: false`; it is reached only by a successor whose timeline does not carry. -- **Gate 4** is in `timeline.rs`, on the direct `adopt_predecessor_plan` path. (The - `successor_swap.rs` case through the plan exchange was deleted in the batch 16a fold.) -- **D6** is out (B16-R4): no seek-report record, bytes or move. Gate 1 has no report clause. - -**Re-pins** (each "the timeline consumer's fixed bytes (#1641 D7)"; ceilings are the new value -plus 10 %, rounded up to 64): - -| Pin | Before | After | Ceiling | -|---|---|---|---| -| `crates/capi/tests/resource_lifecycle.rs` `source_overhead_bytes` (x86-64) | 4,174 | 4,654 | 4,352 -> 5,120 | -| `hosts/host-web/tests/retained_ceilings.rs` `source_overhead_bytes` (x86-64) | 2,658 | 3,138 | 2,688 -> 3,456 | -| `hosts/host-web/tests/retained_ceilings.rs` `source_total_bytes` (x86-64) | 3,682 | 4,162 | 3,840 -> 4,608 | -| `expected.json` `sourceOverheadBytes` (wasm32 simd128, measured) | 2,474 | 2,922 | 2,496 -> 3,264 | -| `expected.json` `sourceTotalBytes` (wasm32 simd128, measured) | 3,498 | 3,946 | 3,648 -> 4,352 | - -The native step is 480 bytes (queue 320, history 16, consumer 144); the wasm32 step is 448 -(consumer 112). capi `source_total_bytes` (12,366 -> 12,846) and `capi_retained_bytes` -(167,754 -> 168,378) stay under their ceilings. `crates/host-core/tests/successor_swap.rs`: five -`carried_source_total_bytes` checks that meant "no ring carried" (`== 0`) or "a ring carried" -(`> 0`) now compare with the timeline's carried queue (`timeline_queue_bytes()`), and -`a_changed_source_gets_its_own_ring` compares allocated plus carried with a fresh plan. - -**Gates** (`crates/host-core/tests/timeline.rs`, new, and one case in `successor_swap.rs`): - -1. `a_source_and_the_timeline_given_the_same_seeks_read_the_same_frame_in_every_block`. -2. `a_rebuild_carries_the_timeline_its_history_and_its_producer`; - `a_latent_successor_keeps_the_newest_entries_in_a_history_of_its_own_size` (K 5 -> 9). -3. `a_discontinuity_keeps_the_sources_and_the_timeline_in_step`; - `a_prime_advances_the_timeline_as_it_advances_a_source`. -4. `the_timeline_carry_and_the_blocks_after_it_allocate_and_free_nothing`. -5. No render arithmetic changed. `./target/release/audit capi`: `pcm_digest` `4f52089e4a8cf83c` - at base and head, 0 violations. The browser oracle's three PCM digests agree with - `expected.json` (`scripts/check-browser-expected-resources.py`, which also measured the wasm - rows above). - -Commands, all PASS: `cargo test --locked` for `source`, `graph --features graph/test-support`, -`host-core --features host-core/test-support`, `capi`, `control-plane`, and host-web -`retained_ceilings`; `realtime_policy` (153 regions, unchanged: the new render code sits in -existing regions; the timeline's pop is a `match`, not a closure, which the policy refuses); -`check-cross-targets.sh`, `check-host-core-policy.sh`, `check-workspace-policy.sh`; clippy -`-D warnings` on the six touched crates; `cargo fmt --all -- --check`. The workspace-wide clippy -was not run (worker rules). - -**Fold (batch 16a review).** - -- **M2.** The D3 flag had no test: dropping `|| program.timeline` (graph) or `timeline_carries ||` - (host-core) passed every test, because the successor always carried a source or strip state - too. `timeline::a_rebuild_that_carries_no_source_still_carries_the_timeline`: the successor - changes the source's declaration and renames the track, so the timeline is the program's only - move (`carried_source_total_bytes` equals the timeline queue's bytes); the timeline continues - with no gap and a seek through the moved producer lands. Red on each of the two removals - (each run). -- **M3.** `adopt_sources` refused every source move when both timelines were vacant. The timeline - clause left `movable`; the timeline moves only from an occupied predecessor into a vacant - successor. `source::tests::carry::the_source_moves_carry_whatever_the_two_timelines_hold` - (all four pairings) is red on the unfixed code (the both-vacant pairing). -- **MINOR 1 (D9).** On a `PredecessorMismatch` the timeline producer has already moved - (`adopt_persisting`) while the successor's vacant timeline advances from 0 and observes no seek, - and nothing reports the vacant timeline itself beyond the mismatch count. Unreachable on both - hosts (#1269 P11), the same contract as a vacant source; stated in the `adopt_persisting` doc. -- **MINOR 2.** The capi `resource_lifecycle` and host-web companion non-vacuity guards compare - `carried_source_total_bytes > carried_source_overhead_bytes`: the timeline queue is overhead - only, a carried ring adds its PCM payload. (Not `timeline_queue_bytes()` as in - `successor_swap.rs`: host-web does not depend on `source`, and this needs no new dependency.) -- **MINOR 3.** `timeline::a_successor_at_another_rate_or_quantum_starts_a_fresh_timeline` (red - when the guard is `successor.is_some()`), - `timeline::a_seek_queued_at_the_swap_block_lands_in_the_successors_first_block` (its unique - catch: a carry that drains the queue, so the seek queued at the swap block is lost), and the - vacant-timeline unit test - `source::tests::timeline::a_vacant_timeline_never_adopted_advances_from_zero`. -- **MINOR 4.** The discontinuity gate's claim that it reads the overlap's older entry is removed: - its plan has `K = 1`, so no overlap is held. The overlap rule is `source`'s - `a_node_block_reports_the_steps_of_a_source_read_step_back` (#1640 fold M1). -- **MINOR 11.** `install_carry_program` refuses `timeline: true` on a plan with no source set - (`NoSourceSet`): `graph::tests::install_refuses_a_timeline_carry_on_a_plan_with_no_source_set`. -- **Test value.** `successor_swap::the_timeline_carry_allocates_and_frees_nothing_through_the_exchange` - is deleted: the exchange owner exposes no timeline to show that the held seek lands, and - `timeline.rs`'s gate 4 already counts the same carry and blocks. -- No charged byte moved (#1640 fold MINOR 6 removed only unread rows); `realtime_policy` 153 - regions, unchanged. -- **Fold 2.** M1 is completed in `source` (#1640 "Fold 2"): a node block now also steps where a - newer entry starts below every older entry after a step-back, so a carried timeline's step-back - reads right in every node block with at most one step. This record's MINOR 3 line now names the - queued-seek test's unique catch. No charged byte moved; `realtime_policy` regions unchanged. diff --git a/.github/ISSUE_SPECS/1642-seek-the-session-from-the-c-abi.md b/.github/ISSUE_SPECS/1642-seek-the-session-from-the-c-abi.md deleted file mode 100644 index 4dbbc0c41..000000000 --- a/.github/ISSUE_SPECS/1642-seek-the-session-from-the-c-abi.md +++ /dev/null @@ -1,602 +0,0 @@ -# Seek the timeline and every source in one C ABI call - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answer A1 (A1.2), under decision 15 D15-16 -(`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`). A draft for root -to file. Code anchors verified on `6ee64f484`. - -## Product outcome - -A C host moves the playhead with one call. `miso_engine_v1_session_seek` (and its anchored form -`miso_engine_v1_session_seek_at`) seeks the session's timeline (#1640 and #1641) and every source of the -newest committed session to one timeline sample, under one new generation, all or nothing. Either -every consumer takes the seek, in the same render block, or nothing changes and the call returns a -typed refusal. The host then refills each source from that frame under that generation. Per-source -seeks stay, for a stem a transaction adds; they never move the timeline. - -## Context - -- **Per-source seeks today.** `SessionState::seek` and `seek_at` - (`crates/capi/src/runtime/control.rs:1514-1543` on `6ee64f484`; in `crates/control-plane` after - #1309) synchronize the plan epochs, then seek one - producer of the newest committed session (`newest_providers`, `:1486-1490`): the pending - candidate's producers while a swap waits, else the running plan's. The shared FFI body - `source_seek_entry` (`crates/capi/src/ffi.rs:574-624`) checks the handle and the source ID, calls - the state and maps a `SourceFailure` to a result code and diagnostic (`SourceFailure`, - `crates/capi/src/runtime/control.rs:1546-1557`). -- **The producer table.** `SourceControlSet` (`crates/host-core/src/source.rs:127-130`) holds every - source producer, its region `0..frames` (`ControlSource`, `:15-26`; filled from the model, - `crates/host-core/src/prepare.rs:1231-1253`) and, after #1641, the timeline producer. Its - `queue_seek` (`crates/host-core/src/source.rs:233-261`) checks the region, then calls the - producer's `try_seek`. Its diagnostics are one table (`SourceControlError::diagnostic`, - `:75-103`): `source.generation.stale`, `source.seek.backpressure`, `source.ring.vacated`, - `source.seek.anchor_unaligned`. -- **Why all or nothing is possible.** Each consumer's command queue has one slot. Only the control - thread pushes and render only pops, so the room a check sees cannot shrink before the push. - #1640 D1 gives every producer `check_seek` (every rule except the push) and `try_seek`. -- **The shared control plane.** *Extract the C ABI control plane into a portable crate both hosts - call* (#1309) moves capi's `SessionState` (today `crates/capi/src/runtime/control.rs`) into - `crates/control-plane`, and lands before batch P1, so before this slice. After *Swap and retire - browser plans through the Worker's service loop* (#1381) D2, the browser's source entry points - call `SessionState`'s source routing too. So both hosts call one wrapper, - `SessionState::session_seek`, over D1's function in `host-core`'s `SourceControlSet`. -- **Feature bits.** The last bit on `6ee64f484` is `MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_AT` (32); - the mask is 63 (`crates/capi/include/miso_engine_v1.h:136-142`; `crates/capi/src/abi.rs:53-71`). - #1316 and #1323 each add a bit; D15-12's growth rule gives this slice the next free bit when it - merges. `scripts/check-capi-abi.sh` holds the frozen exported symbol set (`:192-208`). -- **Header text.** "Sources across a structural transaction" (`miso_engine_v1.h:85-94`) and - "Starting an added stem in time" (`:96-108`). *Document the seek contract and the C ABI growth - rule in the header* (#1317) rewrites the seek text; this slice adds the timeline and the - session seek to that text (D5). -- **The audit.** `audit capi` renders 100,000 calls with one structural transaction outside the - render scope (`tools/audit/src/capi.rs:460-540`) and prints a `pcm_digest` that no gate pins - (`.github/workflows/qualification.yml:759-779` checks only its shape). *Test held seeks across - swaps and supersession, and add a seek to audit capi* (#1319, in this slice's closure through - #1641 and #1320) adds, right after the structural transaction, - `miso_engine_v1_source_seek_at(session, "fixture-source", 2, A, A)` with `A = 4 * QUANTUM_FRAMES` - and one generation-2 quantum from frame `A`, both asserted `RESULT_OK`; call 4's audited render - applies that seek (#1319 D4). - -## Decisions frozen for this slice - -- **D1. One function, in host-core.** `SourceControlSet::seek_session(&mut self, generation: u64, - timeline_sample: u64, anchor_sample: Option) -> Result<(), SourceControlError>`: - 1. `generation` 0 is `GenerationZero`. - 2. Check the timeline producer and every source producer with `check_seek` (#1640 D1) for a - command of generation `g`, frame `min(timeline_sample, region_end)` per source - (`timeline_sample` for the timeline) and the anchor. The first failure is returned and - nothing is pushed: a vacant entry is `Vacated`, a generation not above that producer's is - `Seek(GenerationNotStrictlyIncreasing)`, a full slot is `Seek(Backpressure)`, an unaligned - anchor is `Seek(AnchorUnaligned)`, a timeline frame over the limit is - `Seek(FrameOutOfRange)`. - 3. Push every command (the timeline first, then the sources in ID order). After step 2 no push - can fail, because only this thread pushes to those slots. A push that fails anyway is an - engine invariant: the call returns `Chunk(HostChunkError::InternalInvariant)`, the code hosts - already report as internal (`SourceControlError::is_internal`, `:115-119`), with a - `debug_assert`. - The C ABI's `SessionState::session_seek` synchronizes the epochs and calls it on - `newest_providers_mut().sources`, exactly as `seek` does. `SessionState` is in - `crates/control-plane` (#1309). The browser export (#1643) calls the same wrapper. -- **D2. Which consumers.** The timeline and every source of the newest committed session: while a - structural transaction's candidate waits for its swap, that is the candidate's set, whose - persisting producers feed the rings the running plan renders (#1273 D3). A source the candidate - adds is sought too: it has its own new ring. -- **D3. Generation.** One `g` for every consumer, strictly above each one's current generation. The - host chooses it: one above the largest generation it has used on this session. -- **D4. C entry points.** Session thread, serialized with the other session calls: - ```c - uint32_t miso_engine_v1_session_seek(miso_engine_v1_session *session, - uint64_t generation, uint64_t timeline_sample); - uint32_t miso_engine_v1_session_seek_at(miso_engine_v1_session *session, - uint64_t generation, uint64_t timeline_sample, - uint64_t anchor_sample); - ``` - Results: `OK`; `INVALID_ARGUMENT` for a zero or stale generation, an unaligned anchor or a frame - over the limit; `BACKPRESSURE` with `source.seek.backpressure` when any slot is full; the - existing `SourceFailure::report` mapping for the rest, with the diagnostic in - `miso_engine_v1_last_error`. One feature bit, `MISO_ENGINE_V1_FEATURE_SESSION_SEEK`, covers both - symbols: the next free bit when this merges, never a reused one; code, tests and the smoke - programs name it by its symbol. The mask grows to match; the frozen symbol list grows by two. -- **D5. Header text.** One more topic paragraph in #1317 D1's sequence of seek paragraphs (which - replaces "Starting an added stem in time"), placed right after its `seek_at` paragraph: the timeline is the - session's playhead; it starts at 0 and advances one quantum per render; stored automation follows - it (#1058). `miso_engine_v1_session_seek` moves it and every source in one step, all or nothing; - each source goes to `min(timeline_sample, its frames)`; the host then submits generation `g` - from that frame. The anchored form takes the anchor of `miso_engine_v1_source_seek_at`. A - per-source seek leaves the timeline where it is. A seek of every source is a declared - discontinuity: the host calls `miso_engine_v1_declare_discontinuity` (#1323 D1) first. - `docs/C_ABI_V1_QUALIFICATION.md` gains the same text beside the #1275 paragraph (`:106-122`). -- **D6. The audit.** #1319 D4's anchored source seek, its generation-2 quantum and its `RESULT_OK` - assertions stay as they are. After call 4's audited render has applied that seek (so no seek is - pending in any slot), still outside every render scope, `audit capi` calls - `miso_engine_v1_declare_discontinuity` (#1323 D1), then - `miso_engine_v1_session_seek(session, 3, B)` with `B = 8 * QUANTUM_FRAMES`, and submits one - generation-3 quantum of `fixture-source` from frame `B`. It asserts that each call returns - `RESULT_OK`. Its violation counts stay 0. Its `pcm_digest` moves (the seek changes the audio); no - gate pins it. #1319 D4's liveness witness `replacements == 1` (`tools/audit/src/capi.rs:521-524`) - stays true: on the audit session `miso_engine_v1_declare_discontinuity` publishes nothing. - #1323 D2 prepares a successor only when the active plan has a floor above a node's natural - arrival or a source-read offset above 0. The audit's one structural transaction (an - `UpsertTrack`) raises no floor, and no plan of the audit has a source-read offset, so neither - term holds, the call returns OK and no second plan is swapped in. -- **D7. The acked-batch question.** The call checks every slot before it pushes any (D1), and only - the control thread pushes, so it is acknowledged only when every consumer holds the seek. A held - anchored seek moves with its consumer across a swap. No ack can precede a drop. - -## Deliverables - -1. D1 in `crates/host-core/src/source.rs`, with unit tests. -2. D4 in `crates/capi/src/ffi.rs`, `crates/capi/src/abi.rs`, and the `session_seek` wrapper in - `crates/control-plane/src/` (the file that holds `SessionState` after #1309); - the header prototypes, constants and D5's text; `crates/capi/tests/c/abi_smoke.c`, - `crates/capi/tests/c/header_smoke.cpp`; the frozen symbol list. -3. D5 in `docs/C_ABI_V1_QUALIFICATION.md`; D6 in `tools/audit/src/capi.rs`. -4. Tests in `crates/capi/src/runtime/tests.rs`. - -## Authorized paths - -- `crates/host-core/src/source.rs` (`seek_session` and its unit tests) -- `crates/capi/include/miso_engine_v1.h`, `crates/capi/src/ffi.rs`, `crates/capi/src/abi.rs`, - `crates/capi/src/lib.rs` (exports), the file in `crates/control-plane/src/` that holds - `SessionState` after #1309 (the `session_seek` wrapper only), `crates/capi/src/runtime/tests.rs`, - `crates/capi/tests/c/abi_smoke.c`, `crates/capi/tests/c/header_smoke.cpp` -- `scripts/check-capi-abi.sh` (the frozen symbol list only) -- `tools/audit/src/capi.rs` (D6 only) -- `docs/C_ABI_V1_QUALIFICATION.md` -- `crates/capi/Cargo.toml` (the `graph` dev-dependency gains `features = ["test-support"]`, so - gate 1 can call `graph::test_only_timeline_at`; today it is a plain dev-dependency, - `crates/capi/Cargo.toml:25-27`), `Cargo.lock` - -## Non-goals - -- The browser export and the SDK (#1643 and #1644). -- The seek report's timeline row (#1641 D6). -- The declaration itself (*Reset latency floors at a host-declared discontinuity*, #1323). This - slice does not call it for the host. -- A loop feature. A host loops with an anchored session seek at the end of each pass (README A1.2). -- The protocol's stored transport position (#1635 retires it). - -## Hazards - -- **Hot file.** The header is touched by #1316, #1317, #1323 and others in stream B - (`docs/handoffs/decision-15-2026-10-05/STREAMS.md`). Root orders the merge; this slice takes the - next free bit at that point and rewords nothing it does not own. -- **Pending candidate.** A test that seeks while a candidate waits must see the seek in the - candidate's new rings and in the running plan's carried rings at the same block, through D2. -- **The timeline frame is not clamped.** Only sources clamp to their regions; the timeline takes - `timeline_sample` as given (up to #1640's limit), so automation past the end of every stem - still follows the playhead. - -## Objective gates - -1. **Both forms move everything at one block** (`crates/capi/src/runtime/tests.rs`, new). A - two-source session (sources of 10,000 and 4,000 frames), 48 kHz, quantum 128, playing. After - `miso_engine_v1_session_seek(session, 2, 6_000)` and one generation-2 quantum per source from - its frame, the next rendered block reads frame 6,000 from the long source, the short source is at - its end of region (frame 4,000) and the timeline reads 6,000 (#1641's test reader). With - `session_seek_at(session, 3, 1_000, A)`, `A` two quanta ahead, the timeline and both sources - change at the block whose sample is `A`, not before. -2. **Stale generation changes nothing.** After gate 1, `session_seek(session, 2, 0)` returns - `INVALID_ARGUMENT` with `source.generation.stale`; the next block's output and the timeline equal - a run without the call. A per-source seek to generation 5 first makes a session seek at - generation 4 refuse in the same way. -3. **One full slot refuses all.** Push a per-source seek and render nothing, so that source's slot - is full; `session_seek` returns `BACKPRESSURE` with `source.seek.backpressure`, the timeline's and - the other source's slots stay empty (a following per-source seek on the other source is - accepted), and the next block applies only the earlier per-source seek. -4. **Pending candidate.** Commit a structural transaction that adds a source, then - `session_seek` before the swap: at the swap block the running plan's carried source, the added - source and the timeline all read the seek's frame. -5. **Per-source seeks leave the timeline.** A plain `miso_engine_v1_source_seek` moves its source - and not the timeline. -6. **ABI.** `FEATURE_SESSION_SEEK` is set in `miso_engine_v1_query_capabilities` and the mask - equals the OR of every bit (`crates/capi/src/abi.rs` unit test); `bash scripts/check-capi-abi.sh` - and `bash scripts/check-capi-abi.sh --self-test` pass with the two new symbols. -7. **Realtime and audio.** `./target/release/audit capi` reports 0 violations with the session seek - in its run. Every existing capi and host-core test passes unchanged: no rendered bit moves for a - host that does not call the new functions. -8. **Commands:** - - `cargo test --locked -p capi` - - `cargo test --locked -p host-core --features host-core/test-support` - - `bash scripts/check-capi-abi.sh`, `bash scripts/check-capi-abi.sh --self-test` - - `cargo build --locked --release -p audit -p capi && ./target/release/audit capi` - - `bash scripts/check-workspace-policy.sh`, `bash scripts/check-cross-targets.sh` (README F19, the iOS memset rule) - - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `cargo fmt --all -- --check` - -## Test value - -- Gate 1 turns red if the timeline and the sources apply the seek in different blocks, if a source - is not clamped to its region, or if the anchored form ignores its anchor. No test seeks more than - one consumer today. -- Gate 2 turns red if the generation check runs per consumer while earlier pushes already happened. -- Gate 3 turns red if any slot is pushed before every slot is checked: the partial seek the - acked-batch rule forbids. -- Gate 4 turns red if the call seeks the running plan's producers instead of the newest committed - session's. -- Gate 5 turns red if a per-source seek moves the playhead. -- Gate 6 turns red if the symbols are not exported or the bit is missing or reused. - -## Dependencies - -- #1641 *Give every plan a timeline that carries like a source* (it brings #1640). -- *Document the seek contract and the C ABI growth rule in the header* (#1317): this slice adds - the session seek to the text #1317 writes. -- *Reset latency floors at a host-declared discontinuity* (#1323), which adds - `miso_engine_v1_declare_discontinuity`, named by D5's sentence and called by D6. -- *Test held seeks across swaps and supersession, and add a seek to audit capi* (#1319): D6 builds - on its audit seek (also in the closure through #1641). -- *Extract the C ABI control plane into a portable crate both hosts call* (#1309): the file that - holds `SessionState`. It lands before batch P1. -- Batch: R1. #1643 *Seek the timeline and every source from the browser module export and the - headless SDK* calls the same wrapper. - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **`SessionState`** is in `crates/control-plane/src/control.rs` (#1309 landed): `seek` `:2276`, - `seek_at` `:2301`, `newest_providers_mut` `:2185`. -- **Feature bits.** The last bit is now `MISO_ENGINE_V1_FEATURE_SERVICE` (128), mask 255 - (`crates/capi/include/miso_engine_v1.h:244-252`); D4 takes the next free bit at merge, as written. -- **Decision 17 C13** says a feature reaches the C ABI as a protocol message through - `miso_engine_v1_submit_command`, with no new export. The session seek is source transport (the - host must refill every ring at the new generation; the protocol never carries PCM), the same - family as the existing `miso_engine_v1_source_seek` exports, so D4's two exports follow that - family. Root confirms this reading at filing; the alternative is a protocol command whose reply - carries the generation. -- **Dependencies.** #1317, #1319 and #1323 are still open. - -## Amendment (controls coordinator, batch 16 check, 2026-10-16) - -Checked against `35ac17545`. - -**Landed dependencies.** #1309, #1316, #1317, #1319 and #1323 are closed and on main. #1640 and -#1641 are open, in this batch, and land first (in that order). - -**Moved anchors.** -- `SessionState` (`crates/control-plane/src/control.rs`): `seek` `:4048`, `seek_unserviced` - `:4059`, `seek_at` `:4073`, `seek_report` `:4090`, `newest_providers` `:3547`, - `newest_providers_mut` `:3580`, `declare_discontinuity` `:2154`, `SourceFailure` `:4106`. -- `SourceFailure::report` is the trait method in `crates/capi/src/runtime/error.rs:124-128`. -- `crates/capi/src/ffi.rs`: `miso_engine_v1_source_seek` `:525`, `_seek_at` `:556`, - `source_seek_entry` `:582-639`, `_source_seek_report` `:652`, `_declare_discontinuity` `:774`. -- `crates/host-core/src/source.rs`: `SourceControlSet` `:137-140`, `ControlSource` `:21-31`, - `queue_seek` `:271-296`, `seek_report` `:258`, `diagnostic` `:86-112`, `is_internal` - `:127-129`. The producer table is filled at `crates/host-core/src/prepare.rs:2265-2286`. -- `ControlSource` now holds `region_start..region_end`, not `0..frames`. host-core always sets - `region_start` to 0 (`prepare.rs:2269`, `:2283`), so D1's `min(timeline_sample, region_end)` is - always inside the region. -- `scripts/check-capi-abi.sh`: the frozen list is `:248-268`, 19 names. It grows to 21. -- `audit capi` (`tools/audit/src/capi.rs`): `run` `:518-599`; the liveness witness - `replacements == 1` is `:579-582`; `apply_structural_transaction` `:193-249`, with #1319 D4's - anchored seek at `:225-237`; `SEEK_ANCHOR_SAMPLE` `:61`. The qualification job checks only the - digest's shape (`.github/workflows/qualification.yml:834-854`). The digest on main is - `4f52089e4a8cf83c`. -- `docs/C_ABI_V1_QUALIFICATION.md`: the #1275 paragraph is still `:106-122`; the session-call - list `:168-169`; the seek report `:194-219` (frozen count "19" at `:215`); #1317's seek - contract `:221-230`; ABI growth `:231`. - -**Feature bits.** The last bit is `MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_REPORT` (512), mask 1023 -(`crates/capi/include/miso_engine_v1.h:418-428`; `crates/capi/src/abi.rs:53-87`). The next free -bit today is 1024 (mask 2047). D4 still takes the next free bit at merge. The new bit joins the -`features` array of `masks_and_result_codes_are_frozen` (`abi.rs:588-625`), which checks the mask -is the OR of every bit. - -**Header text (#1317 shipped).** "Starting an added stem in time" is gone. The seek paragraphs -are "The source-read clock" `:272`, "Seek." `:281`, "Anchored seek." `:293-299`, "Choosing the -anchor." `:301-310`, then Held, Replacement, One seek in flight, "Seeks across a structural -transaction", "Far past anchors", Rate and "Where a seek landed" (`:350-370`). D5's paragraph -goes right after "Anchored seek." The prototypes are at `:629-643`. "Declared discontinuity" -(`:95-117`) already says the host declares "before a seek of every source". Two more lists name -every session call and must gain the two new symbols: the thread table (`:19-20`) and "Control -service" (`:181-182`). - -**Stale text.** -- `crates/capi/Cargo.toml` needs no edit: the `graph` dev-dependency already has - `features = ["test-support"]` (`:32`, #1639). Drop that path and `Cargo.lock`. -- `crates/capi/src/lib.rs` needs no edit: it re-exports `ffi::*` (`:11`). -- D1 "synchronizes the epochs": today `seek` and `seek_at` run the service step - (`self.service()`), which includes the epoch sync. `session_seek` does the same. -- **Gap for D1 step 2.** `SourceControlSet` holds `HostChunkProvider`, which exposes only - `try_seek` (`crates/source/src/lib.rs:1039`). #1640 D1 puts `check_seek` on the seek-producer - type, but neither #1640 nor #1641 names a `HostChunkProvider::check_seek`. Either #1640 adds the - forward (its paths cover `crates/source/src/lib.rs`), or this slice needs that path. - -**D6 still holds.** #1323 shipped the condition D6 relies on: -`PlanStateInventory::runs_behind_natural_timing` is `raised_arrivals` non-empty or -`source_read_offset > 0` (`crates/host-core/src/prepare.rs:1017-1019`). When neither holds, -`declare_discontinuity` returns `Ok` and publishes nothing (`control.rs:2154-2162`). The audit's -transaction adds a new muted track (no carried floor on a new node) and its route. No audit plan -has a source-read offset. So the declaration publishes nothing and `replacements == 1` stays true. -Code reading only; gate 7's run is the proof. Declare and seek after call 4's render, outside -every render scope, as written. - -**Decision 17.** C13 calls seeks transport under C1, with one core function behind both hosts -(`docs/rulings/one-control-abstraction-2026-10-13.md:287-309`). But the root's widened list of -2026-10-16 does not name `miso_engine_v1_session_seek` or `_session_seek_at`, and says a new -direct export not in the list needs its own root ruling. Root rules before this slice merges. -`SessionState::session_seek` is the one core function; capi keeps only the pointer checks and -the result mapping. - -**Hazard.** A new `impl` block in a render-path crate can move the impl index that -`scripts/check-web-audioworklet-callgraph.py` pins (see `c48cc3875`). Add `session_seek` inside -the existing `impl SessionState`. - -## Amendment (root rulings, batch 16, 2026-10-16) - -- **B16-R1.** `miso_engine_v1_session_seek` and `_session_seek_at` are in decision 17 C13's list - of approved direct exports (seek transport behind `SessionState::session_seek`). -- **B16-R2, the publish fence.** A session seek is one committed act, so every consumer sees it in - the same render block, on both hosts. D1 as filed does not give that for a plain seek: render - pops the timeline's queue and each source's queue at different instants while the session - thread pushes, so a plain seek pushed during a block can split across two blocks. The fix: - - One atomic published-generation word per session, shared by every source producer, the - timeline producer and the source-set driver of every plan of that session. It is carried to a - successor with the producers, so a fresh and a carried consumer of one plan read the same - word. It is allocated on the control thread, never on render. - - The driver loads it once per block entry (acquire), before the timeline and the sources, and - passes the value to each consumer's observe step (#1640 D1's shared function). - - A consumer whose next command (read with the queue's `peek`, which #1320 added) has a - generation above the loaded value leaves it in the queue for a later block. Nothing is - dropped; the slot stays full, so a new push is still backpressure. - - Every seek push ends with a release store of `max(word, g)`: the per-source seek after its - one push, the session seek after its last push (D1 step 3). So all of a session seek's - commands become visible to render at one block entry. - - The session seek also refuses a generation not above the word (`source.generation.stale`), - so a source removed with a higher generation cannot let a command through early. This matches - D3's rule for the host (one above the largest generation it has used on the session). - - One atomic load per block. No allocation, lock or syscall on render; no `engine/realtime` - edit is expected. If one is needed, one attempt; on a refusal stop and report. -- **Paths added for B16-R2.** `crates/source/src/lib.rs` (the word in the driver, the observe - step and the producers' push), `crates/host-core/src/prepare.rs` and - `crates/host-core/src/source.rs` (the word shared into each plan's source set and carried with - the producers), and `crates/control-plane/src/control.rs` only if the carry needs it. -- **New gate 1b (concurrency).** A native test runs a session thread that pushes plain and anchored - session seeks (each one strictly newer) while a render thread renders, for many iterations - (at least 10,000 seeks). In every block, either the timeline and every source apply the seek or - none of them does: no seek is split across blocks. Run at both bank widths the native build - covers. The render thread's allocation and free counts stay 0 (`bench_support::alloc` thread - counters after warming). - -## Decision record: tranche 1, core (batch 16) - -Built on `454d65688` (worktree `codex/controls-batch-16b`). Tranche 2 (D4-D6: C exports, bit, -header, docs, audit, capi gates 1-7) follows. - -**What shipped:** - -- **B16-R2, the publish fence** (`crates/source/src/lib.rs`). `PublishedGeneration`, one - `Arc` per session: `new(initial)` (control thread), `publish(g)` (`fetch_max`, - `Release`: the word is `max(word, g)` and never falls), `generation()`, `retained_bytes()`, and - a crate-private block-entry load (`Acquire`). Equality is identity (`Arc::ptr_eq`). - `SeekProducer` holds `Option`; its `push` is the old `try_seek` without a - publication, and `try_seek` is `push` then publish. `PcmSourceProducer`, `HostChunkProvider` and - `TimelineProducer` gain `try_seek_unpublished` and `share_published_generation`; their - `try_seek` publishes after its one push. The shared observe step's pop is - `SeekState::take_published(queue, published)`: `peek(0)` (#1320), and pop only a command whose - generation is at most the loaded value; otherwise the command keeps its slot. The PCM consumer - and the timeline call it at every observe. The driver holds `Option`, - loads it once at `begin_block` entry, before the timeline and the sources, and passes the value - to both (and to `prepare_source_seek`). `prepare_session_graph_source_set` seals a session's - set with the word; `prepare_graph_source_set` (standalone, no word, no fence) is unchanged. - Superseded by 'Fold (batch 16b review), part A' below. -- **No acked PCM is dropped by the fence.** The PCM consumer's acquire now peeks each block before - it pops it: a block of an unobserved generation gets the one re-observe, and if its seek is - still unobserved (fenced) the acquire stops and leaves the block queued behind its command. - Before, the acquire popped the block first and, had the re-observe found nothing, would have - discarded it. For an unfenced consumer the outcome is the old one in every case the old - reasoning allowed. -- **One word per session** (`crates/host-core/src/prepare.rs`). `PlanStateInventory` holds it: a - first plan allocates it, a successor takes `base.inventory`'s, `at_discontinuity` keeps it and - `across_withdrawn` clones it, so every plan of a session, its fresh rings (a source a candidate - adds or redeclares), its carried consumers, its timeline and a donation's two sides share one - object. `test_only_floors_only` (a fresh reference) gets a word of its own. Every fresh producer - and the fresh timeline producer share it before their first seek; the driver gets it through - `prepare_session_graph_source_set`; `SourceControlSet` holds it (`ControlSourceBuilder::finish`). - `adopt_persisting` debug-asserts both sets hold the same word. -- **D1** (`crates/host-core/src/source.rs`). `SourceControlSet::seek_session(generation, - timeline_sample, anchor_sample)`: zero is `GenerationZero`; a generation not above the word is - `Seek(GenerationNotStrictlyIncreasing)` (`source.generation.stale`); then `check_seek` on the - timeline and every source in ID order (vacant is `Vacated`), each source at - `min(timeline_sample, region_end)` (then `max(region_start)`, a no-op while host-core sets 0); - then every `try_seek_unpublished` (timeline first), then one `publish(g)`. A failed push after - the checks is `Chunk(HostChunkError::InternalInvariant)` with a `debug_assert`; the pushed - commands are published so none stays held. `published_generation()` reads the word. -- **SessionState::session_seek** (`crates/control-plane/src/control.rs`, inside the existing - `impl SessionState`): the service step, then `seek_session` on `newest_providers_mut().sources`. - -**Departures:** - -- **The word is charged in the inventory, not the source rows.** `PlanStateInventory::retained_bytes` - gains `PublishedGeneration::retained_bytes()` (24 bytes on x86-64), because the inventory is - the owner that holds the word across plans and the capi drop oracle frees it there - (`capi_retained_bytes_charge_every_byte_the_compile_retains` passes unchanged). No source row, - pin or ceiling moved. Each kept inventory charges it once, an overcharge in the safe direction. -- **Four test literals** of `SourceGraphSourceSetDriver` in `crates/source/src/lib.rs` and one of - `PlanStateInventory` in `prepare.rs` gain the new field (`published: None`, a fresh word): - field additions only, no assertion changed. -- **`crates/host-core/tests/timeline.rs`**: `seek_both` (gates 1 and 3 of #1641) now issues one - `seek_session` instead of a source seek plus `test_only_timeline_producer().try_seek`. The other - `test_only_timeline_producer` uses stay: they seek the timeline alone. -- **A per-source seek pushed during a block** with a generation above the word now applies at the - next block, not in the block the push raced (B16-R2's rule for every command). Only a - concurrent host sees it; no existing test does. - -**Gates (tranche 1):** - -- D1 unit tests, `crates/host-core/src/source.rs` `source::tests`: - `both_forms_move_the_timeline_and_every_source_clamped_to_its_region`, - `a_refusal_pushes_nothing_and_publishes_nothing`, `a_vacant_entry_refuses_the_whole_seek`. PASS. -- Fence unit tests, `crates/source/src/lib.rs` `tests::published`: - `an_unpublished_seek_and_its_pcm_wait_in_their_slots_until_published` (red with the fence - removed), `a_seek_on_its_own_publishes_its_generation_and_the_word_never_falls`. PASS. -- **Gate 1b**, `crates/host-core/tests/session_seek.rs` - `concurrent_session_seeks_reach_the_timeline_and_every_source_in_one_block`: a session thread - pushes 10,000 session seeks (about half anchored, some late; generation `k + 2`; each pushed at a - random instant up to 40 µs after a block starts) while a render thread renders; every timeline - step is in the block both sources' seek reports name, at the seek's frames (the short source - clamped), at Simd8 and Simd4; render-thread allocations, reallocations and frees 0 over every - measured block. About 12 s for both widths (debug). With the fence removed (`take_published` - ignoring the value) it fails in each of two runs, at seeks 297 and 1,368 (`short` one block - ahead of the timeline and `long`). -- Commands: `cargo test --locked -p source`, `-p host-core --features host-core/test-support`, - `-p control-plane`, `-p capi`: PASS. `./target/release/audit capi`: `pcm_digest` - `4f52089e4a8cf83c` (unchanged), 0 violations. `realtime_policy`: ok, 153 regions (unchanged). - Clippy `-p source -p host-core -p control-plane --all-targets --all-features -D warnings`, - `cargo check --workspace --all-targets --all-features`, `cargo fmt --all`: clean. - -## Decision record: tranche 2, the C ABI (batch 16) - -Built on `51ec4fd67` (tranche 1). - -**What shipped:** - -- **D4.** `miso_engine_v1_session_seek(session, generation, timeline_sample)` and - `miso_engine_v1_session_seek_at(session, generation, timeline_sample, anchor_sample)` in - `crates/capi/src/ffi.rs`: one shared body (`session_seek_entry`) with the handle check and the - `SourceFailure::report` mapping around `SessionState::session_seek`. `FEATURE_SESSION_SEEK = 1 << - 10` (1024); the mask is 2047. `masks_and_result_codes_are_frozen` gains the bit in its `features` - array (the mask is the OR of every bit) and pins it; `version_and_capabilities_are_exact` checks - it is reported. No C export of the published generation (not approved). -- **Header.** The two prototypes after `miso_engine_v1_source_seek_report`; the constant and - mask; D5's paragraph, "Session seek (issue 1642)", right after "Anchored seek.", which also - says a host may pick the next generation as one above the largest it has used; the two symbols - in the thread table and in "Control service" (that paragraph rewrapped, no word changed but the - two names). -- **Smoke programs.** `abi_smoke.c` pins the bit and the mask, adds it to - `reports_every_defined_feature`, checks it against the queried mask and calls both symbols - with a null session for `INVALID_ARGUMENT`; `header_smoke.cpp` pins the bit inside the mask and - both signatures. `scripts/check-capi-abi.sh`: the frozen list 19 -> 21 (the self-test is - generic and needs no change). -- **Docs.** `docs/C_ABI_V1_QUALIFICATION.md`: the D5 paragraph (with the fence and the frozen count - 21), and the two calls in the service paragraph's list of session calls. -- **D6.** `tools/audit/src/capi.rs`: before call 5 (`SESSION_SEEK_CALL`, right after call 4's - render applied the anchored seek), outside every render scope, `declare_discontinuity`, then - `session_seek(session, 3, 8 * QUANTUM_FRAMES)`, then one generation-3 quantum of - `fixture-source` from that frame, each asserted `RESULT_OK`. - -**Departures:** - -- **One `engine/realtime` edit (accepted on its one attempt).** - `RealtimePlanOwner::active_plan_mut` in `crates/engine/src/realtime/plan_exchange.rs`, inside - the existing `impl RealtimePlanOwner` (no new impl block), so a capi test can read the active - plan's timeline (`crate::ffi::test_plan_timeline_at`, over `graph::test_only_timeline_at` - through control-plane's test-support `test_owner_mut`). No other reader of the active plan - existed; gate 1 names #1641's reader. It is a borrow for the plan's exclusive owner only. -- **The D5 paragraph sits after "Anchored seek." in the header** as the batch-16 check says; in - the qualification doc it follows the seek-report paragraph (#1316), not the #1275 one, so the - frozen counts there stay in order (15, 17, 19, 21). -- **Test helper.** `crate::ffi::test_session_seek` (the runtime test module forbids `unsafe`). - -**Gates** (`crates/capi/src/runtime/tests.rs`, `runtime::tests::session_seek`): - -1. `both_forms_move_the_timeline_and_every_source_at_one_block`: `session_seek(2, 6_000)` lands - long at 6,000, short at 4,000 and the timeline at 6,000 in one block; `session_seek_at(3, 1_000, - A)` holds all three two blocks and lands them at `A`. -2. `a_stale_generation_changes_nothing`: `session_seek(2, 0)` refuses `source.generation.stale` - and the next block's bits and timeline equal a twin's; a per-source seek to 5 makes a session - seek at 4 stale through the published generation alone (the timeline is at 2), and pushes - nothing. -3. `one_full_slot_refuses_every_consumer`: `BACKPRESSURE` with `source.seek.backpressure`; the - short source's slot and the timeline's stay empty. -4. `a_seek_before_the_swap_reaches_the_carried_and_the_added_source_and_the_timeline`. -5. `a_source_seek_leaves_the_timeline`. -6. `masks_and_result_codes_are_frozen`, `version_and_capabilities_are_exact`, - `check-capi-abi.sh` and its `--self-test`. -7. `audit capi`: 0 violations, `replacements == 1` and the carry assertion hold. `pcm_digest` - moved `4f52089e4a8cf83c` -> `bffc21e857b5e134`: call 5 now plays the generation-3 quantum where - it rendered underrun silence before (the anchored seek's one quantum ran out at call 4). -- **`cargo doc -D warnings` repairs (mechanical, outside the paths).** Four pre-existing - public-doc links to private items: three in `crates/source/src/lib.rs` (`TimelineConsumer`, - `begin_block_at` and `prime_ready`, from #1640/#1641) and one in - `crates/control-plane/src/compile.rs:734`, each turned into a code span. - -**Root ruling B16-R8 (2026-10-16).** `RealtimePlanOwner::active_plan_mut` is gated behind a new -`engine/test-support` feature (`#[cfg(feature = "test-support")]`), because its only caller is test -code. capi's dev-dependency on `engine` turns the feature on. The workspace debug-test and doctest -steps of `qualification.yml` name `engine/test-support`, so `scripts/check-test-support-ci.py` -passes (13 packages). The release `capi` build does not see the accessor. - -## Amendment (root ruling B16-R9, batch 16b review, 2026-10-16) - -- **D5 amended.** Its last two sentences ("A seek of every source is a declared discontinuity: the - host calls `miso_engine_v1_declare_discontinuity` (#1323 D1) first.") are replaced by: "A plain - session seek is a declared discontinuity; the engine declares it in the same call, and a refused - seek declares nothing. An anchored session seek keeps continuity." A seek of every source made - with per-source seeks is still the host's own call, so `miso_engine_v1_declare_discontinuity` - keeps its duty text for that case and for a stop. -- **D1 amended (the core).** `SessionState::session_seek` without an anchor runs, in order: the - service step; `SourceControlSet::check_session_seek` on the newest providers' set (every rule of - `seek_session` except the pushes and the publication); the declaration, through the same core - path as `declare_discontinuity` (all or nothing); then `seek_session` on the newest providers. A - refused check declares nothing and pushes nothing; a refused declaration pushes nothing and - returns its own error. With an anchor nothing is declared: it is the loop and exact-start form - (#1643), and #1323 D2 publishes the successor `Next`, at the next block, before the anchor, so the - effects would go to rest and PDC drop while the old pass still plays. -- **D6 amended.** `audit capi` makes no separate declaration: the session seek declares itself. -- **Gates added.** A refused plain session seek publishes nothing; an accepted one publishes exactly - one discontinuity successor and its seek applies; an anchored one publishes no successor; each on - the C ABI and alike on the browser (the parity row), over #1285's floors (a latent insert removed, - so a declaration has a successor to publish). -- **B15-R7's order is retired** (no host and no SDK calls the declaration before a session seek; - recorded in #1323's and #1643's specs too). - -## Fold (batch 16b review), part B / B16-R9 - -**What shipped.** - -- **Core.** `crates/control-plane/src/control.rs`, inside the existing `impl SessionState` (no new - impl block): `session_seek` returns `Result<(), SessionSeekFailure>`, a new enum with `Seek(SourceFailure)` - and `Declaration(CommandError)`, exported from the crate root. `declare_discontinuity` is now its - service step plus `declare_discontinuity_serviced`, the one core path both calls run. The plain - form checks, declares, then calls `seek_session` on the newest providers; a refusal of that push - maps to `SourceFailure::Control(Chunk(InternalInvariant))` behind a `debug_assert`. The comment - says why the set is the one checked: the discontinuity successor carries every source of the - committed model, and its commit moves each producer from the set it supersedes (the checked - donation, then `adopt_persisting` for the persisting producers and the timeline), with no push in - between. -- **C ABI.** `session_seek_entry` maps `Seek` by `SourceFailure::report` and `Declaration` by the new - `declaration_refused`, the mapping `miso_engine_v1_declare_discontinuity` already used (now shared), - so the result codes stay consistent. Header: the session seek paragraph states B16-R9 and gains - the full refusal list (MINOR a: `source.seek.anchor_unaligned` for `_at`, `source.ring.vacated`, - `INTERNAL` with `capi.source.epoch` or `source.internal`, and the declaration's refusals for the - plain form); the declared-discontinuity paragraph's duty is now "at a stop, and before a seek of - every source made with per-source seeks". `docs/C_ABI_V1_QUALIFICATION.md`: the same text, a new - B16-R9 paragraph, the #1323 duty line, and MINOR b ("at or below which"). -- **Browser.** `ReadyOwnership::session_seek` runs the plain form through `run_control` (made generic - over its result and error, with the refusal for a full table queue passed in), so a plan the - declaration publishes keeps its tables and counts as a blocking rebuild, as - `declare_discontinuity`'s does; the anchored form calls the core directly. A refused - declaration reports through `refuse_control`, as the browser's declaration does. The exports pass - the spectrum promotion analysis, as `declare_discontinuity`'s export does. -- **Audit (D6).** `tools/audit/src/capi.rs` no longer calls `miso_engine_v1_declare_discontinuity`. - `pcm_digest` `bffc21e857b5e134`, unchanged: the audit session has no raised floor and no - source-read offset, so the declaration publishes nothing both before and after (the separate call - and the seek's own declaration are both no-ops there); 0 violations, `replacements == 1` holds. -- **MAJOR 1 (host part).** `runtime::tests::session_seek::a_removed_sources_generation_keeps_a_session_seek_stale`: - a per-source seek of the short source to 10, a transaction that removes it, the swap, then a - session seek at 5 is `source.generation.stale` and pushes nothing; 11 is accepted. The false - comments in `a_stale_generation_changes_nothing` (capi and host-web: the long source's own - generation refuses that case too) are corrected. - -**Gates (each red with its rule removed).** - -1. A refused plain seek publishes nothing: `capi` `runtime::tests::session_seek_declares::a_refused_plain_session_seek_publishes_nothing` - (red when the declaration runs before the check). -2. An accepted plain seek publishes one successor and applies: `...::an_accepted_plain_session_seek_publishes_one_successor_and_applies` - (after the swap and while the removal's candidate waits; red with the declaration removed). -3. An anchored seek publishes none: `...::an_anchored_session_seek_publishes_no_successor` (red when - the anchored form declares). -4. Both hosts alike: `control_parity::a_plain_session_seek_declares_and_an_anchored_one_does_not_alike_on_both_hosts` - (48 and 96 kHz; red under each of the three mutations). -5. The headless SDK: `sdk/test/capability-evals.mjs` (see #1643's record). - -The floors come from #1285's path: the limited session (a muted track behind a true-peak limiter, -removed by a transaction) on the C ABI, and t3's limiter removed on the parity session. - -### Fold (batch 16b review), part A - -The unfenced mode is gone (finding M2). The session's published generation is now a required argument of `PcmSourceRing::prepare_host_region`, `SourceTimeline::prepare`/`prepare_vacant` and `prepare_session_graph_source_set`; `prepare_graph_source_set` and the `u64::MAX` sentinel are removed. Every producer publishes to its word. Every consumer and timeline holds the word and loads it at each block entry; the driver loads it once for the whole set. `PcmSourceRing::prepare(config)` stays: it gives the ring a word of its own, so the ring is fenced as a session of one. A set refuses a timeline or occupied consumer of another word (`SourceGraphSourceSetError::SessionWord`), and a carry or swap between sets of different words is refused. The only push without a publish is the source-crate `SessionSeek` (`PublishedGeneration::session_seek`), which owns the zero and stale rules and checks that each producer is of its word (`SessionSeekError::ForeignSession`). It publishes once, on `publish()` or drop, and only if something was pushed. host-core's `seek_session` uses it, and the new `check_session_seek` applies the same rules with nothing pushed or published; part B calls it before it declares the discontinuity. For M1, host-core `source::tests::a_generation_not_above_the_word_is_stale_when_every_producer_takes_it` (word at 10, seek at 5 is stale while every producer would take it) is red with the stale rule disabled, when every other host-core source test stays green. `published::a_session_seek_opens_above_its_word_and_pushes_only_its_own_producers` and `published::a_set_takes_only_consumers_of_its_session` are red under their mutations. Render stays allocation-, lock- and syscall-free: 153 policy regions; capi `pcm_digest` `bffc21e857b5e134`. - -Fold 3 (batch 16b fold verification): `session_seek_declares::a_refused_declaration_pushes_nothing_and_leaves_the_generation_free` gates the refused-declaration rule; PcmSourceRing::prepare(config) stays ungated because no host path reaches it (only crates/source integration tests: prime.rs, randomized.rs, seek_schedule_model.rs), root ruling B16-R10's condition. diff --git a/.github/ISSUE_SPECS/1643-seek-the-session-from-the-headless-engine.md b/.github/ISSUE_SPECS/1643-seek-the-session-from-the-headless-engine.md deleted file mode 100644 index 2f898abdc..000000000 --- a/.github/ISSUE_SPECS/1643-seek-the-session-from-the-headless-engine.md +++ /dev/null @@ -1,391 +0,0 @@ -# Seek the timeline and every source from the browser module export and the headless SDK - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answer A1 (A1.2), under decision 15 D15-16 -(`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`). A draft for root -to file. Code anchors verified on `6ee64f484`. - -## Product outcome - -The shipped engine module exports the session seek of #1642: one function on both hosts, with no -browser-only seek logic in Rust. It seeks the timeline and every source to one frame under one new -generation, all or nothing, and every consumer applies it at the next render block. The headless -SDK moves the playhead with one call, `seek(frame)`, which resolves with the generation it used; -the caller then submits each source's PCM from that frame under that generation. Until a source's -new PCM arrives it underruns, as after any seek today. The browser SDK and the PCM feed use this -export in #1644. - -## Context - -- **The browser's per-source seek.** The export `miso_engine_web_v1_source_seek` - (`hosts/host-web/src/ffi.rs:3787-3812`) reads the staged source ID and calls - `AudioWorkletEngineHost::seek_source` (`hosts/host-web/src/lib.rs:3170-3208`), which seeks the - producer in `ready.host.sources` (a `SourceControlSet`) and then calls - `plan.prepare_source_seek` (`:3193-3203`). Errors map through `source_result` (`:6543`). These - are the anchors of `6ee64f484`; before this slice lands, *Move browser source submission and - seeks into the Worker* (#1387) moves the seek to the Worker on an isolated page, and *Swap and - retire browser plans through the Worker's service loop* (#1381) moves the set into the Worker's - `SessionState`. Both arrive through #1293, a dependency. -- **#1642's function.** `SourceControlSet::seek_session(generation, timeline_sample, anchor)` - in host-core checks the timeline and every source producer, then pushes to all of them. - `SessionState::session_seek` in `crates/control-plane` wraps it, as `seek` wraps the source seek. - The C ABI calls the wrapper; this slice makes the browser call the same wrapper, as #1381 D2 makes - it call `SessionState`'s other source routing. -- **Headless.** `OfflineEngine.seekSource` (`sdk/src/headless/engine.ts:299-305`; class at `:114`) - calls `WasmBoundary.seekSource`, which calls the export directly - (`sdk/src/core/boundary.ts:1082-1107`). The existing headless seek eval is at - `sdk/test/capability-evals.mjs:162`. -- **Export mirrors.** `scripts/check-web-audioworklet.sh` (`expected_exports`, `:203`), - `tools/parameter-metadata/src/abi_layout.rs` (`EXPORTS`, `:135`), `scripts/check-abi-layout-v1.py` - (`:191`) with its fixture `scripts/fixtures/abi-layout-v1-self-test.json`, - `sdk/assets/miso-engine-v1-abi-layout.json`, `sdk/src/generated/abi.ts` and the hermetic mock in - `scripts/test-web-audioworklet.mjs` (`:2277`). -- **The anchored browser form** is *Export transaction apply and anchored seek from the browser - engine module* (#1293), which lands first. This slice exports the anchored session seek beside - #1293's anchored source seek (D1). - -## Decisions frozen for this slice - -- **D1. The export.** `miso_engine_web_v1_session_seek(handle, generation: u64, frame: u64) -> u32`. - It calls #1642's `SessionState::session_seek` (#1642 D1's wrapper, which synchronizes the - epochs and calls `SourceControlSet::seek_session(generation, frame, None)` on the newest - providers), the source routing every browser source entry point uses after #1381 D2, on the - control half #1387 D2 picks (the Worker in `worker` mode, the worklet's control handler in - `single` mode). It maps an error through `source_result`. It does **not** call `prepare_source_seek`: every consumer - observes the seek through its command queue at the next block, the timeline included, so all of - them change in one block. On an instance that holds no control half it refuses with - `RESULT_REFUSED_LIFECYCLE` and changes nothing, the rule #1293 D1 sets for every control-half - export. Beside it, `miso_engine_web_v1_session_seek_at(handle, generation: u64, - frame: u64, anchor: u64) -> u32` passes `Some(anchor)`, with #1293's anchor rules, so a browser - host loops or starts the session in exact time (README A1.2). -- **D2. Headless `seek`.** `OfflineEngine.seek(frame: bigint, options?: { generation?: bigint }): - { generation: bigint } & EngineCallResult` calls a new `WasmBoundary.sessionSeek` once, which - calls the export. The generation is `options.generation`, else one above the largest generation - this engine object has passed through `seek`, `seekSource` or `submitSource` (every source and - the timeline start at 1). A stale generation is refused with `source.generation.stale` and - nothing changes. The caller then submits each source's PCM from its clamped frame, - `min(frame, source frames)`, with that generation, as after `seekSource`. -- **D3. Mirrors.** The two new exports join every list in the context; the generated SDK files are - regenerated with the repository's generators; every checker's self-test passes. -- **D4. The acked-batch question.** The export is #1642's all-or-nothing function: an OK means - every consumer holds the seek, and a refusal changes nothing. No ack can precede a drop. - -## Deliverables - -1. D1 in `hosts/host-web/src/ffi.rs` and `hosts/host-web/src/lib.rs`, with a native test. -2. D2 in `sdk/src/headless/engine.ts` and `sdk/src/core/boundary.ts`, with an eval. -3. D3's mirrors and generated files. - -## Authorized paths - -- `hosts/host-web/src/ffi.rs`, `hosts/host-web/src/lib.rs`, `hosts/host-web/web/` and - `crates/control-plane/src/` as #1387 and #1381 leave them (the session seek export, its routing - beside the source seek's, and its call into #1642's function only), - `hosts/host-web/src/tests.rs` -- `sdk/src/headless/engine.ts`, `sdk/src/core/boundary.ts`, `sdk/src/generated/abi.ts`, - `sdk/assets/miso-engine-v1-abi-layout.json`, `sdk/test/capability-evals.mjs` -- `scripts/check-web-audioworklet.sh` (export list only), `scripts/check-abi-layout-v1.py`, - `scripts/fixtures/abi-layout-v1-self-test.json`, `scripts/test-web-audioworklet.mjs` (the mock - export only), `tools/parameter-metadata/src/abi_layout.rs` -- No new path for gate 1: `hosts/host-web/src/tests.rs` reads the timeline through #1641's - host-core re-export (host-web has no `graph` dependency). - -## Non-goals - -- The shipped host message, the browser SDK `seek` and the PCM feed's ring rule (#1644). -- The anchored source seek's export (#1293), which lands first. -- Any change to the routing of *Move browser source submission and seeks into the Worker* (#1387), - which lands first (through #1293): the session seek takes the thread the source seek takes. -- Any change to the per-source seek's semantics. - -## Hazards - -- **Stream H owns host-web** (`docs/handoffs/decision-15-2026-10-05/STREAMS.md`). Root sequences - the merge with #1293, #1332 and #1387. -- **A browser app that keeps using ring seeks** after a session seek at generation `g` makes the - feed call the per-source export with `g`, which is refused as stale and stalls that ring. No - browser path calls the new export until #1644 adds its ring rule; both merge in batch R1. -- **The shipped artifact changes.** Report its new digest and size in the PR; the committed pin is - the release fingerprint and is not re-pinned here (`scripts/build-web-audioworklet.sh:6-10`). - -## Objective gates - -1. **One block for everything** (`hosts/host-web/src/tests.rs`, new). A two-source session at - quantum 128, playing. Call the `extern "C"` export with generation 2 and frame 6,000, submit one - generation-2 quantum per source from its clamped frame, render one block: both sources read - their seek frames and the timeline reads 6,000 (#1641's test reader) in that block. A stale - generation returns `RESULT_INVALID_ARGUMENT` and the next block equals a run without the call. - One full source slot returns `RESULT_BACKPRESSURE` and nothing changes. -2. **Headless** (`sdk/test/capability-evals.mjs`, new case beside the seek case at `:162`). After - `seek(4096n)` and one quantum per source at the returned generation, the next render reads frame - 4,096 from a source. The SDK has no timeline reader and no stored automation renders before - #1647, so this case does not observe the timeline; gate 1 does, through the same export - (#1641's reader, re-exported by host-core). A second `seek` without - options uses the next generation. A stale explicit generation returns the typed refusal. -3. **Mirrors.** Each mirror list contains the export, and the checkers fail if one omits it. -3a. **Role and pending candidate** (`hosts/host-web/src/tests.rs`, new). Each of the two exports, - called on an instance without a control half, returns `RESULT_REFUSED_LIFECYCLE` and changes - nothing (as #1293 gate 5). Commit a structural transaction that adds a source through - `miso_engine_web_v1_apply` (#1293), then call the session seek before the swap: at the swap - block the carried source, the added source and the timeline all read the seek's frame (#1642 - gate 4, on the browser). -4. **No rendered bit moves** for a session that never calls the new export: the browser legs of the - `browser` job in `.github/workflows/qualification.yml` pass with unchanged digests. -5. **Commands:** - - `cargo test --locked -p host-web --features host-web/test-support` - - `mkdir -p target/ci/qualification-artifacts target/ci/qualification-named-twin && bash scripts/build-web-audioworklet.sh --named-twin target/ci/qualification-named-twin target/ci/qualification-artifacts` - - `bash scripts/check-web-audioworklet.sh --without-metadata-regeneration target/ci/qualification-artifacts target/ci/qualification-named-twin/miso-engine-v1-audio-worklet.simd128.named.wasm` - - `bash scripts/test-web-audioworklet.sh` - - `bash scripts/check-sdk-generated.sh target/ci/qualification-artifacts`, - `bash scripts/check-sdk-types.sh`, - `bash scripts/check-sdk-headless.sh target/ci/qualification-artifacts` - - `python3 -B scripts/check-abi-layout-v1.py` with its self-test - - `bash scripts/check-cross-targets.sh` (README F19, the iOS memset rule: no new - `memset_pattern16` call; fix one in code, never by a ceiling) - - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `cargo fmt --all -- --check`, `bash scripts/check-workspace-policy.sh` - -## Test value - -- Gate 1 turns red if the browser export seeks consumers in different blocks (for example through - `prepare_source_seek` for the sources only), seeks with logic of its own, or pushes before every - slot is checked. -- Gate 2 turns red if the headless SDK picks a stale generation or does not call the session seek - export. -- Gate 3 turns red if a mirror lets the SDK call a name the module lacks. -- Gate 3a turns red if the export runs on the render half, or if the browser seeks the running - plan's producers instead of the newest committed session's while a candidate waits. - -## Dependencies - -- #1642 *Seek the timeline and every source in one C ABI call* (its D1 function; #1640 and - #1641 come with it). -- *Export transaction apply and anchored seek from the browser engine module* (#1293): the - anchored export's rules and its mirrors. -- Batch: R1. #1644 *Seek the timeline and every source from the browser SDK and the PCM feed* - builds on it. - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **Single instance first** (#1562's decision 17 amendment). The control half is the worklet's - message handler (`single` mode); #1332 later deploys the same exports in the Worker with no other - logic. D1's "the Worker in `worker` mode" waits for #1332 and adds nothing here. -- **Anchors.** `miso_engine_web_v1_source_seek` is `hosts/host-web/src/ffi.rs:3251`; the control - exports of #1562a are `:3308-3383`. -- **Dependencies.** #1293 (open) for the anchored export's rules; #1381a has landed, #1381 and #1387 - are open. Decision 17 C13 applies as in #1642's amendment. - -## Amendment (controls coordinator, root rulings, 2026-10-15) - -- **Gate 3a uses a dropped export.** Root ruling B15-R8 (2026-10-15, recorded in #1293) drops #1293's - D1 staging, and decision 17 dropped `miso_engine_web_v1_apply`. Gate 3a commits its structural - transaction as a `SESSION_TRANSACTION_APPLY` frame through #1562's generic entry - (`miso_engine_web_v1_control_request_ptr` and `miso_engine_web_v1_control_command`), and reads - the outcome from the response frame. The rest of the gate is unchanged. -- **Discontinuity first.** Root ruling B15-R7 (2026-10-15, recorded in #1323): the headless `seek` - (D2) calls `miso_engine_web_v1_declare_discontinuity` (#1323) before it calls the session seek - export, as a C ABI host declares before its session seek (#1642). So browser latency floors reset - as they do on the C ABI. `WasmBoundary` gets the call. #1323 adds the export and its mirrors and - is a dependency. Gate 2 asserts the declaration comes first. - -## Amendment (controls coordinator, batch 16 check, 2026-10-16) - -Checked against `35ac17545`. - -**Landed dependencies.** #1309, #1316, #1323 (with `miso_engine_web_v1_declare_discontinuity`, -`hosts/host-web/src/ffi.rs:3354`) and #1562 are closed and on main. #1642 and #1293 are open and in -this batch; both land first. #1387 and #1332 stay open: `single` mode only, as the amendment of -2026-10-14 says. #1644 is open and not in this batch. - -**Moved anchors.** -- `miso_engine_web_v1_source_seek` is `hosts/host-web/src/ffi.rs:3251-3276`. `seek_source` is - `hosts/host-web/src/lib.rs:3533-3565`; it calls `ready.control.seek_unserviced` (`:3551-3553`), - then `plan.prepare_source_seek` (`:3556`). `ready.control` is the `SessionState` (#1381a). -- D1 "maps an error through `source_result`": `SessionState` returns `SourceFailure`, so the map - is `source_failure_result` (`lib.rs:5197`), which calls `source_result` (`:5181`). -- Headless: `OfflineEngine` is `sdk/src/headless/engine.ts:125`; `submitSource` `:419-427`; - `seekSource` `:429-435`. `WasmBoundary.seekSource` is `sdk/src/core/boundary.ts:1144-1167`. The - headless seek eval is `sdk/test/capability-evals.mjs:197`. -- Mirrors: `expected_exports` `scripts/check-web-audioworklet.sh:274`; `EXPORTS` - `tools/parameter-metadata/src/abi_layout.rs:126` (105 today, 108 after #1293, 110 after this - slice) and `scripts/check-abi-layout-v1.py:120`. The list is sorted: `session_seek*` goes after - `service`, #1293's `source_seek_*` after `source_seek`. Merge #1293 first. -- The mock in `scripts/test-web-audioworklet.mjs` (`:2741`) lists only exports the worklet script - calls. This slice adds no worklet call, so the mock needs no edit. - -**The code's truth.** -- **No generation tracking today.** The headless SDK passes the caller's generation straight - through (`seekSource`, `submitSource`); it keeps no generation state. D2's default adds the - first such state to `OfflineEngine`. -- **No discontinuity call today.** No SDK file calls `miso_engine_web_v1_declare_discontinuity` - (only `sdk/src/generated/abi.ts` names it). B15-R7's `WasmBoundary` call is new here. -- **Prepare.** The browser's plain seek prepares the consumer at the call, so new PCM enters at - once. D1's session seek does not (C ABI parity): a ring full of old-generation PCM refuses the - first submission with backpressure until one block renders. Gates 1 and 2 feed rings with room. -- **Thread.** In `single` mode the worklet instance is the control half, so only the caller keeps - the export out of `process()` (the SDK calls it from the message handler, as for - `declare_discontinuity`). Gate 3a's "not ready" case cannot show a render-half call. -- **Paths.** `crates/control-plane/src/` and `hosts/host-web/web/` need no edit: - `SessionState::session_seek` is #1642's, and no worklet script changes. -- Gate 3a's structural transaction goes through #1562's control entry, as amended on 2026-10-15. - -**Hazard (#1644 not in this batch).** After this slice, the headless `seek` is the export's only -SDK caller. The browser SDK and the PCM feed do not call it until #1644, so the stale-ring stall -in the hazards cannot happen in this batch. The headless default generation counts `seekSource` -and `submitSource` too, so a headless caller that mixes them stays ahead. - -**Decision 17.** D1 calls the one core function (`SessionState::session_seek`); host-web keeps -pointer checks and the result map. Open points are in the coordinator's report: the two new -exports and the C13 list, the SDK-chosen generation (D2), and a parity row in -`hosts/host-web/tests/control_parity.rs` (browser session seek against the C ABI session seek). - -## Amendment (root rulings, batch 16, 2026-10-16) - -- **B16-R1.** The two exports `miso_engine_web_v1_session_seek` and `_session_seek_at` are in - decision 17 C13's list of approved direct exports (seek transport behind - `SessionState::session_seek`). -- **B16-R5, D2 stands.** The SDK chooses the generation, one above the largest it has used. It is a - host transport token, as on the C ABI (#1642 D3). The core refuses a stale one (and, under - #1642's B16-R2 fence, a generation not above every generation published on the session). -- **B16-R6, new gate 6 (parity row).** In `hosts/host-web/tests/control_parity.rs` (now an - authorized path): a plain session seek and a late anchored session seek on both hosts at 48 and - 96 kHz, two sources of different lengths. After each block, from the call through the applying - block and after, the browser and C ABI seek reports of both sources are equal field by field, - and the timeline (#1641's host-core test reader) reads the same value on both hosts. Red if - either host splits the seek across blocks, clamps differently or drops the lateness. It needs - #1642's C export. - -## Decision record: #1643 (batch 16) - -Built on `5567f7487` (worktree `codex/controls-batch-16b`). - -**What shipped.** -- **D1.** `miso_engine_web_v1_session_seek(handle, generation, frame)` and - `miso_engine_web_v1_session_seek_at(handle, generation, frame, anchor)` in - `hosts/host-web/src/ffi.rs`, one shared body `AudioWorkletEngineHost::session_seek(generation, - frame, Option)` in `lib.rs`: it calls `SessionState::session_seek` (the C ABI's call, - after its service step) and maps a refusal through `source_failure_result`. host-web holds no seek - logic and never calls `prepare_source_seek`. A host that is not ready refuses with - `RESULT_REFUSED_LIFECYCLE` and changes nothing; both exports run the service step, so the page - calls them from the message handler only (neither is `render_locked`). -- **D2.** `OfflineEngine.seek(frame, options?) -> { generation } & EngineCallResult` - (`sdk/src/headless/engine.ts`) and `WasmBoundary.declareDiscontinuity()` / `sessionSeek()` - (`sdk/src/core/boundary.ts`). `sessionSeek` calls `miso_engine_web_v1_declare_discontinuity` - first (B15-R7), stops at its refusal, then calls the session seek export. The generation is - `options.generation`, else one above the largest this object has passed (starts at 1). The - counter (`#highestGeneration`) is the only state added; `seek`, `seekSource` and `submitSource` - raise it on an `ok` result only, so a refusal (backpressure, stale) never moves it. -- **D3.** Mirrors: `check-web-audioworklet.sh` `expected_exports`, `abi_layout.rs` `EXPORTS` (108 -> - 110), `check-abi-layout-v1.py` `EXPORTS` and the fixture, `sdk/assets/miso-engine-v1-abi-layout.json` - and `sdk/src/generated/abi.ts` (regenerated with `node codegen/assets.mjs` and `node - codegen/generate.mjs`). The ABI self-test gains two mutations (each new export dropped): 36 - caught, from 34. The test-worklet mock needed no edit. - -**Departures.** -- **Test-support plumbing outside the listed paths** (mechanical): `hosts/host-web/Cargo.toml` - `test-support` now also names `control-plane/test-support` and `engine/test-support`, so a native - host-web test reaches the active plan's timeline through `PlanState::test_owner_mut` and - `RealtimePlanOwner::active_plan_mut` (B16-R8) and `host_core::test_only_timeline_at`: - `AudioWorkletEngineHost::timeline_at_for_test` and `native_staging::timeline_at`, both - `cfg(feature = "test-support")`. The workspace test steps already name both features - (`check-test-support-ci.py` passes, 13 packages). No file under `crates/engine/src/realtime/` - changed. -- **Gate 6: the C ABI's timeline is not read.** `capi::Plan` and `Session` expose no accessor - outside the crate (`test_plan_timeline_at` is `cfg(test)` and crate-private), and a new public - capi test hook is not mechanical. The parity row therefore compares the browser's and the C - ABI's seek reports of all three sources field by field (at the call, after every block through - the applying one and after), the output bits, and reads the browser's timeline: it moves in the - applying block, runs on one quantum per block, and in the late seek lands on the frame the long - sources' reports (equal on both hosts) name. The C ABI's timeline is pinned by capi's own gate - (`runtime::tests::session_seek`). The row is `cfg(feature = "test-support")`. -- **Gate 1/6: the short source is not fed at its clamped frame.** A source clamped to its end has - no frame to submit there (a chunk from the end is `RESULT_INVALID_ARGUMENT`), so the long source - is fed and the short source underruns; its report still names the clamped frame. -- **Gate 1's full-slot case** holds the slot with an anchored `source_seek_at` (which, unlike the - plain `source_seek`, does not prepare the consumer and so does not drain the slot). -- **A refused session seek still declares the discontinuity first** (the SDK holds no - generation state beyond its counter, so it cannot know the core would refuse). The declaration - resets latency floors and publishes nothing else; it is the C ABI host's order. -- **Gate 4** (no rendered bit moves) is the unchanged `browser` job digests; this slice adds no - render code and no worklet call. Not run here (CI job). - -**Gates -> tests.** -- Gate 1 (`hosts/host-web/src/tests.rs`, `tests::session_seek`): - `both_forms_move_the_timeline_and_every_source_at_one_block` (plain seek at 2/6,000: long at - 6,000, short at 4,000, timeline 6,000 in one block; anchored seek held two blocks then landing - at the anchor), `a_stale_generation_changes_nothing` (next block bits and timeline equal a run - without the call; a per-source seek to 5 makes a session seek at 4 stale), - `one_full_slot_refuses_every_consumer` (`RESULT_BACKPRESSURE`; the short source's slot and the - timeline stay untouched). All call the `extern "C"` exports. -- Gate 2 (`sdk/test/capability-evals.mjs`): "session seek declares the discontinuity first and moves - the sources in the next block" (export call order `declare_discontinuity`, `session_seek`; output - equals a `seekSource` twin and is not silent; default generations 2, 3, 10, 13, 14; a stale - explicit generation is `invalidArgument`; usage errors reach no export). -- Gate 3: `check-web-audioworklet.sh` (compares the wasm's exports with `expected_exports`), - `check-abi-layout-v1.py` and `--self-test` (36 mutations), `check-sdk-generated.sh`. -- Gate 3a (`tests::session_seek`): - `the_session_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing` and - `a_seek_before_the_swap_reaches_the_carried_and_the_added_source_and_the_timeline` (the - transaction goes through `native_staging::control_command`, the `control_command` export; the - seek before the swap reaches the carried source, the added source and the timeline in the swap - block, active epoch 0 -> 1). -- Gate 6 (`hosts/host-web/tests/control_parity.rs`): - `the_session_seek_is_alike_on_both_hosts_for_plain_and_late_seeks` (48 and 96 kHz). - -**Commands run (all pass).** `cargo test --locked -p host-web --features host-web/test-support` -(lib 145 passed, control_parity 20 passed, every other binary green); `cargo clippy --locked -p -host-web -p parameter-metadata --all-targets --all-features -- -D warnings`; `cargo fmt --all -- ---check`; `RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps -p host-web --all-features`; -`build-web-audioworklet.sh --named-twin`; `check-web-audioworklet.sh`; `test-web-audioworklet.sh`; -`check-sdk-generated.sh`, `check-sdk-types.sh`, `check-sdk-headless.sh` (565 SDK tests pass); -`check-abi-layout-v1.py` (document and `--self-test`); `check-session-map-shape.py` (and -`--self-test`); `check-cross-targets.sh` (PASS, no new `memset_pattern16` call); -`check-workspace-policy.sh`; `check-test-support-ci.py`; `cargo run -p realtime-policy` (153 -regions, unchanged). The workspace-wide clippy runs at the batch gates. - -**Artifact.** `miso-engine-v1-audio-worklet.simd128.wasm`: 4,207,840 bytes, sha256 -`2fc4d8aaa1b9fb49f18c71cc8f2e0fc8fd1f096b96324e86bc6312e64bf92a74` (named twin 4,830,433 bytes, -`f6cb1e2c5d2a5d1ea88327d6331c2473ff7eeaa5d5f75eff550a8b19a2521b01`). The committed pin is not -re-pinned. - -**Successors.** (1) A capi test hook that reads the active plan's timeline would let the parity row -compare both timelines directly. (2) #1644 (browser SDK `seek` and the PCM feed's ring rule) is the -next caller of the exports. - -## Amendment (root ruling B16-R9, batch 16b review, 2026-10-16) - -- **B15-R7's order is retired.** The headless `seek` (D2) and `WasmBoundary.sessionSeek` no longer - call `miso_engine_web_v1_declare_discontinuity`: a plain session seek is a declared discontinuity, - and the engine declares it in the same call, after every check, so a refused seek declares - nothing. An anchored session seek keeps continuity and declares nothing (the loop and exact-start - form; README A1.2). `WasmBoundary.declareDiscontinuity()` stays for a stop. -- **Gate 2 amended.** It asserts the SDK does not call the declaration before a session seek (the - call log is `["session_seek"]`), and a new eval holds the B16-R9 rules on the headless path. - -## Fold (batch 16b review), part B / B16-R9 - -- **SDK.** `sdk/src/core/boundary.ts` `sessionSeek` and `sdk/src/headless/engine.ts` `seek`: no - declaration call; the doc comments say the engine declares in the same call. The browser export - (`miso_engine_web_v1_session_seek`) runs the plain form through the host's `run_control`, so a - plan the declaration publishes keeps its tables and counts as a blocking rebuild; the anchored - export declares nothing. -- **Evals (`sdk/test/capability-evals.mjs`).** `session seek calls the session seek export alone and - moves the sources in the next block` (gate 2, the call log is `["session_seek"]`; red with the - SDK's declaration restored) and `a plain session seek declares the discontinuity itself, and a - refused one declares nothing` (B16-R9 gate 4 on the parity session: t3's limiter removed through - the new `remove_t3_limiter` structural frame, so #1285 floors hold the latency; a stale plain seek - keeps the floors, an accepted one resets them, and no separate declaration is called; red with - the SDK's declaration restored, and red against an artifact built with the core's declaration - removed). The headless SDK has no anchored session seek, so rule 3 is held on the C ABI and the - parity row only. -- **Fixture.** `hosts/host-web/tests/structural_frames_fixture.rs` writes one more frame, - `remove_t3_limiter` (`RemoveTrackEffect` of t3's limiter), into - `hosts/host-web/tests/fixtures/structural-frames.json` (blessed; the byte gate passes). -- **Browser tests.** `tests::session_seek::the_session_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing` - now asserts no seek waits in a slot after the refusals (`native_staging::session_seek_slots_empty`, - test-support; MINOR f; red when the export pushes before its lifecycle refusal); its doc says the - failed host keeps its control state. The `one_full_slot_refuses_every_consumer` comment is - corrected (MINOR e: the slot frees at the next block's pop; it is full only because no block - renders between the calls). diff --git a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md index 90f781bfd..d6856a452 100644 --- a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md @@ -319,3 +319,128 @@ Checked against `12d4a4f9d`. `SessionState`) has landed; #1562b (the browser's rebuild path) is a dependency of batch R1, because every fader automation edit is a rebuild after #1648. - **Must-land group** is #1646, #1647, #1648, #1649 and #1650. + +## Amendment (controls coordinator, batch 17 spec check, 2026-10-16) + +Checked against `31fa33fee`. + +**Anchors moved.** + +- Inert-table text: `docs/SESSION_SCHEMA_V1.md:286-291` (was `:220-225`). The builtin target list + left `validate.rs` (#1566); the fader row is `BUILTIN_PARAMETER_DESCRIPTORS` row `id: 5` + (`crates/builtins/src/lib.rs:488`, `:596-598`), and the row rule is + `controls::automation_diagnostics` (`crates/controls/src/automation.rs:89`). +- Composition: `session::vca_effective_db` and `VcaSum` (`crates/session/src/vca.rs:26-67`, + member first); the one caller that composes is `controls::effective_strip` + (`crates/controls/src/effective.rs:311-334`); preparation takes `controls::effective` output + (`crates/host-core/src/prepare.rs:2756`). `pub use` is `crates/session/src/lib.rs:38` (not `:31`). + The schema sentence of D6 is `docs/SESSION_SCHEMA_V1.md:83-84` (not `:80`). +- Order test: `crates/controls/tests/composition.rs:82-83`, its `reference_db` `:37-45`, its rustdoc + `:47-52`. Host oracle: `crates/host-core/tests/vca.rs:270-279`. The two browser oracles of D6 are + gone (#1565). +- `checked_fader_gain` `crates/builtins/src/lib.rs:5298`, `db_gain` `:6430`. +- `FaderBankProcessor` `crates/builtins-compiler/src/lib.rs:1519-1700`; fused + `FaderMatrixBankProcessor` `:1831` (`process` `:2334`); `FaderCells` `:298`, `FaderLane` `:266`; + `SEAM_SIDE_WITNESS` `:1141`; `track_mono_source` `:5184`; bank bytes + `graph_builtin_bank_resource` `:3593` and `strip_processor_bytes` `:2480`. +- `TrackStage::PostFader` `crates/graph/src/lib.rs:258-266`; `checked_add_builtin_banks` `:666-698`. + Host admission `host.graph.resource.limit` `crates/host-core/src/prepare.rs:2939`. Replacement + admission `crates/control-plane/src/compile.rs:824` onward. +- `CanonicalFpEnv` `crates/lane/src/fpenv.rs:432` (wasm form `:527`); render enters it at + `crates/host-core/src/render_session.rs:121`. + +**Landed deps and what they give.** + +- #1637: `automation::{ProgramBuilder, CellDescription, CellState, CellEvents, NodeSpan, + JumpLength::Block}`. `ProgramBuilder::bytes` is the byte report. No program field holds `a(n)`. + `CellState::new` has no target and no setter exists. +- #1638: `BuiltinFaderBank::apply_fader_operation(lane, channels, FaderOperation::SetGain)` + (`crates/builtins/src/lib.rs:4591`), and the same on `FaderMuteRampBuiltins` (`:5198`). +- #1639: `GraphJumpLengths` and its reader (`crates/graph/src/lib.rs:1234-1430`), charged by + `graph::jump_length_retained_bytes()` in `prepare.rs`. +- #1312: `LatestCell` is generic. A three-word cell already exists in production: the + jump-length cell is `LatestCell<3>`. Deliverable 2 and gate 5 fall away; no file under + `crates/engine/src/realtime/` is edited. +- #1285: floored arrivals are `GraphCompileReport::input_arrivals`, known only after + `compile_with_builtins` (`prepare.rs:2810`), which runs after the builtins are prepared. +- #1562a/b, #1309: both hosts prepare through host-core's one backend (the C ABI through + `control-plane`; the browser calls four `prepare_host_runtime_*` forms, `hosts/host-web/src/lib.rs:5575-5655`). +- #1650 has not landed: no hand-offset field exists yet. + +**Departures from the filed text.** + +1. **`a(n)` is not this slice's.** The builder has no `a(n)` field, and D2 proves the prepared value + is the first `start_value` for every `a(n)`. So this slice needs no `a(n)`. It moves to #1647 + (ruling Q1 below). +2. **The scalar track is the test-only oracle.** Shipped builds bank every strip; the per-node + `LiveControlFaderProcessor` exists only under `test-support` (#1059, `lib.rs:3704-3723`). See + ruling Q2. +3. **Cell bytes.** `LatestCell<3>` is not 36 bytes: each slot also holds a `u64` key and a `u64` + sequence. Charge `size_of::>()` (96 bytes, measured on x86-64) plus its + writer, reader and dirty share, as measured, never a literal. The README F16 figure is wrong in + the same way; it is still a fixed size per lane, so F16's decision stands. +4. **Charge every retained byte.** D4 also charges each cell's `CellState` (32 bytes) and the per-lane + automation slots on the fader processor, not only the program and the offsets cell. +5. **Seed, not write.** A #1312 cell cannot be written without a gate stamp. Seed `S` as #1639 does: + the render reader holds the prepared words until its first applied write. This is not a "static + copy in the program"; the cell is still the one source after the first write. +6. **Thick core (decision 17 C1, C7).** `S` per lane is a derived value: `controls::effective` + returns it (a new `EffectiveStrip` field, from `vca_offsets_sum`), and preparation reads it from + the `Effective` argument; no other crate computes it. The fader row's cell parameters (grid 64, + grid ramp 64, `End`, `JumpLength::Block`, `SeekMode::Exact`) and the segment conversion + (`stored_segment`, `crates/controls/src/automation.rs:282`, now private) are written once in + `controls`, so #1651-#1667 reuse them. Host-core builds the `Program` and passes it to the + builtins preparation with `&effective`. +7. **Every builtins preparation form installs it.** The four forms in `prepare.rs:2757-2797` + (selected meters, between render calls, live controls) all install the program, so the result + does not depend on which form a host calls. +8. **Event state lives in `FaderBankProcessor`** (builtins-compiler), per lane and channel, beside + the cell readers. #1648 extends `FaderBankProcessor::adopt_lane` (`lib.rs:1557`) to move it; + `crates/builtins` is not edited. +9. **The seed method lands here.** A fresh `CellState` has no target, so #1647's first grid event + would always emit. This slice adds the one seeding method to `crates/automation/src/events.rs` + and seeds each cell with its prepared curve value; #1648 reuses it. +10. **D2's mechanism.** Setting the lane's prepared fader parameter to `vca_compose_db(first value, S)` + through the existing `checked_fader_gain` path gives the same bits as a `SetGain`; either is D2. +11. **Strips, not tracks.** Submix strips carry the same fader bank; D1-D4 apply to every strip. + +**Ruling questions.** + +- **Q1. How `a(n)` reaches the fader stage** (decided in #1647; repeated there). Options: + (A) at the end of `compile_with_builtins` the graph compiler hands each builtin bank its members' + floored input arrivals through a new defaulted method on `GraphPreparedBuiltinBankProcessor`; + (B) host-core installs them after the compile through a new artifact method that downcasts the + fader banks; (C) the graph runtime keeps each lane's arrival and passes a node-time sample per + lane. **Recommendation: A** (one core rule, no host step, the value sits beside the lane's + program). +- **Q2. The test-only scalar oracle.** Options: (A) bank only; a `Backend::Scalar` preparation of a + session with stored fader automation is refused with a typed diagnostic; (B) install the program + on the oracle too, with one event driver shared by both processors, as #1638 gave both its + operations. **Recommendation: B**: it keeps the oracle a full reference for "a placement change + moves no bit" (about 1 h more). + +**Authorized paths, as they now must read.** + +- `crates/session/src/vca.rs`, `crates/session/src/lib.rs` (`:38` only), + `docs/SESSION_SCHEMA_V1.md` (`:83-84` only) +- `crates/controls/src/effective.rs` (`S` per lane, `VcaSum` order), `crates/controls/src/automation.rs` + and `crates/controls/src/lib.rs` (the fader row's cell descriptions), `crates/controls/tests/composition.rs` +- `crates/host-core/src/prepare.rs`, `crates/host-core/tests/vca.rs` (`reference_db` only), + `crates/host-core/tests/stored_fader_automation.rs` (new), `crates/host-core/Cargo.toml` + (`automation`), `crates/builtins-compiler/Cargo.toml` (`automation`), `Cargo.lock` +- `crates/builtins-compiler/src/lib.rs` (program, event state, offsets cell and its writer handle, + seed, prepared value, bank bytes; the oracle per Q2) +- `crates/automation/src/events.rs` (the seed method and its test) +- `crates/capi/src/runtime/tests.rs`, `hosts/host-web/src/tests.rs` (gate 1) +- No path under `crates/engine/src/realtime/`. `crates/engine/src/realtime/latest_cell.rs` is + removed from the list. + +**Gates' test files.** Gate 1: `crates/capi/src/runtime/tests.rs`, `hosts/host-web/src/tests.rs`. +Gates 2-3: `crates/host-core/tests/stored_fader_automation.rs`. Gate 5: dropped (no new cell form). +Gate 6: `crates/controls/tests/composition.rs`. Gate 7 adds `cargo test --locked -p controls` and +`-p automation`, and drops the loom command. + +**Size.** About 9 implementer hours. Split into the smallest closable slices, in this order: +**#1646a** D6 only (session, controls effective and its test, host-core oracle, schema sentence; +about 2 h, closable alone: class B, ruled) and **#1646b** the preparation (about 7 h). Both stay in +batch R1. diff --git a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md index b50c86b28..51a78d4f9 100644 --- a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md @@ -257,3 +257,111 @@ three #1638 reviews measured; the mechanism is this slice's design, reviewed wit - #1285 landed first (batch 15b). `a(n)` above must therefore be the floored input arrival (`GraphCompileReport::input_arrivals`, kept in `PlanStateInventory::input_floors()`), not the arrival the graph compiler computes without floors. + +## Amendment (controls coordinator, batch 17 spec check, 2026-10-16) + +Checked against `31fa33fee`. + +**Anchors moved.** + +- `FaderBankProcessor` `crates/builtins-compiler/src/lib.rs:1519`; its `begin_block` (the drain) + and `process` `:1630-1660` (`process` calls `bank.process`, not `process_with`). Fused + `FaderMatrixBankProcessor` `:1831`, `process` `:2334` (fuses through + `try_process_settled_with_matrix`). +- The chain call is in `rack`, not graph: `Runtime::execute` (`crates/graph/src/runtime.rs:3188`) + calls `BankChain::run` / `run_with_resident_input` (`:3310-3318`); `BankChain::run` (`crates/rack/src/lib.rs` + about `:3230-3245`) calls each slot's `begin_block` then `process(BankBlock)`; the adapter + `BuiltinStage` (`runtime.rs:4325-4349`) forwards to `GraphPreparedBuiltinBankProcessor::process` + (`crates/graph/src/lib.rs:1737`) and `begin_block` (`:1782`). +- The source set's block entry and the read clock: `crates/graph/src/lib.rs:4983-4996` + (`source_read_sample = absolute_sample + source_read_offset`, #1396). The jump lengths are read at + `:4965-4971` into a local and are not passed on. +- `#1640`'s reader is `TimelineConsumer::timeline_block(first_source_read_sample: u64, frames) -> + TimelineSpan` (`crates/source/src/lib.rs:2791`); the driver trait has only `timeline_at` + (`crates/graph/src/lib.rs:2672`; impl `crates/source/src/lib.rs:3361`). +- D4: the inert text is `docs/SESSION_SCHEMA_V1.md:286-291`; `validate.rs` no longer says the table + is inert (`:868-870` now says "structure only"), so `crates/session/src/validate.rs` leaves the + list. `docs/REALTIME_MEMORY.md:13` is unchanged. + +**Landed deps and what they give.** + +- #1640/#1641: every host-core plan has a timeline (`prepare.rs:2949-2990`), history + `K = ⌈A_max/q⌉ + 1`. #1637 shipped node time as `timeline(r + ΣP - a(n))`. +- #1639: `GraphJumpLengths` held per block in the executor; `LiveRamps::for_row` + (`crates/controls/src/live_ramps.rs:44`). +- #1638: `BuiltinFaderBank::process_with`, `FaderOperationSink`, `gain_held`, `ramp_remaining`, + `BuiltinProcessReport::fader_operations_stopped` (`crates/builtins/src/lib.rs:160`, `:4564-4625`). +- #1642/#1643, B16-R9: `SessionState::session_seek` (`crates/control-plane/src/control.rs:4119`) is the + one core function behind `miso_engine_v1_session_seek[_at]` and + `miso_engine_web_v1_session_seek[_at]`. A plain seek declares a discontinuity first; when the + newest plan has a raised floor or `ΣP > 0`, that publishes a successor whose owners start at + rest, so its automated lanes start fresh (D2's "restarts" case). An anchored seek declares + nothing. +- #1285: `a(n)` is the floored arrival, known only after `compile_with_builtins`. + +**Departures from the filed text.** + +1. **`process_timed` cannot be a defaulted forward through the chain** without editing `rack` + (`BankStage`/`BankBlock` carry no context). Ruling Q2. +2. **`a(n)` has no path to the fader stage** (the program holds none; #1646 now leaves it here). + Ruling Q1. +3. **Negative node samples.** At `r + ΣP < a(n)` the node block starts before source-read sample 0, + which `timeline_block(u64, ..)` cannot take. The driver-trait method and the source reader take a + signed first sample and extrapolate back as `timeline_at` does (README A1.4, "New lanes": + node time `-a(n)` at render 0). This widens the `crates/source/src/lib.rs` path to the reader. +4. **A restart's first-block `Set`** (D2) has no seek step to come from: the processor builds that + block's `NodeSpan` with a step at offset 0 (`NodeSpan::new(t, Some((0, t)), q)`), so + `SeekMode::Exact` emits the `Set`. +5. **The scalar track is the test-only oracle** (#1646 Q2). With #1646 Q2 = B, its render path gets + the same event driver; with A, nothing here. +6. **Counters.** D1's refusal counter and the stage's `fader_operations_stopped` are + `FaderBankProcessor` fields read off render (a test-support read and its qualification counters); + name them in the brief. Each condition is unreachable and `debug_assert`s. +7. **Jump lengths.** Build `LiveRamps` from the three held words through one `controls` constructor + (the link length equals the fader length), so the render path writes no row rule. +8. **Parity (decision 17 C1).** Gate 4 has no browser leg; both hosts now have a session seek behind + one core function, so add it (`hosts/host-web/src/tests.rs`, through + `miso_engine_web_v1_session_seek`). Gate 4 also covers a plan with a raised floor, so the plain + seek's discontinuity successor is tested (D2 restart path). + +**Ruling questions.** + +- **Q1. How `a(n)` reaches the fader stage.** (A) at the end of `compile_with_builtins` the graph + compiler hands each builtin bank its members' floored input arrivals through a new defaulted + method on `GraphPreparedBuiltinBankProcessor`; (B) host-core installs them after the compile + through a new artifact method that downcasts the fader banks; (C) the graph runtime keeps each + lane's arrival and passes a node-time sample per lane. **Recommendation: A** (one rule in the core, + no host step; adds `crates/graph-compiler/src/compile.rs`). +- **Q2. How the block's node-time context reaches the processor.** (A) `rack`'s `BankBlock` (or a + new `BankStage` method) carries the source-read sample, the timeline view and the jump lengths + (adds `crates/rack/src/lib.rs`); (B) graph's runtime sets the context on each builtin slot + through `builtin_processor_mut` (`runtime.rs:4389`) with a new defaulted + `GraphPreparedBuiltinBankProcessor` method before `chain.run`, and `process` reads it; `rack` is + not edited. **Recommendation: B** (no change to the generic chain contract; the context is + graph's own). + +**Authorized paths, as they now must read.** + +- `crates/builtins-compiler/src/lib.rs` (fader bank, fused processor, the oracle per #1646 Q2) +- `crates/graph/src/lib.rs` (the context method, the driver trait's signed `timeline_block`, the + arrivals method of Q1), `crates/graph/src/runtime.rs` (the context set before `chain.run`; the + `GraphSourcePlanes` view), `crates/graph-compiler/src/compile.rs` (Q1 = A only) +- `crates/source/src/lib.rs` (`TimelineConsumer::timeline_block` signed form and the driver's + `timeline_block`, with their tests) +- `crates/controls/src/live_ramps.rs` (the constructor of departure 7) +- `crates/host-core/tests/stored_fader_automation.rs`, `crates/capi/src/runtime/tests.rs`, + `hosts/host-web/src/tests.rs` (gate 4's browser leg) +- `docs/SESSION_SCHEMA_V1.md` (`:286-291`), `docs/REALTIME_MEMORY.md` (`:13`) +- No path under `crates/engine/src/realtime/`. Every edited render function sits in a + `REALTIME_POLICY` region and `scripts/check-realtime-policy.sh` must stay green. + +**Gates' test files.** Gates 1-3, 5, 6, 8: `crates/host-core/tests/stored_fader_automation.rs`. +Gate 4: the same file, `crates/capi/src/runtime/tests.rs` and `hosts/host-web/src/tests.rs`. +Departure 3: a unit test beside the reader in `crates/source/src/lib.rs`. Gate 7 adds +`cargo test --locked -p source`, `-p controls`, `-p control-plane` and (Q1 = A) `-p graph-compiler`. + +**Size.** About 16 implementer hours: too big for one slice. Split, in this order, all in batch R1: +**#1647a** the plumbing (Q1, Q2, the signed reader; a test-support probe shows each lane's +`NodeSpan`, `a(n)` and jump length per block; no audible change; about 4 h); **#1647b** events on +the fader (D1.1-D1.5, D2, D3, D4; gates 1-6; about 7 h); **#1647c** the held-ramp obligation of +the batch 13b amendment (gate 8; about 5 h). diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index c1e3187b4..8e5d325fa 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -278,3 +278,97 @@ Checked against `12d4a4f9d`. - **Gate.** Add to the objective gates: `control_smoothing` changed on a session with no reading automation, then a fader entry added: from the adoption block, the block holds the new lengths (`graph::test_only_block_jump_lengths`). + +## Amendment (controls coordinator, batch 17 spec check, 2026-10-16) + +Checked against `31fa33fee`. This amendment overrides the filed text and the earlier amendments +where they differ. + +- **Anchors moved.** `classify_live_delta` `crates/host-core/src/live_delta.rs:341`, its body + `classify_live_delta_with_monitor` `:360-466`; the automation mask line `:376-378`; the step list + in the rustdoc `:252-285` and the model-only paragraph `:287-302`. The steps are now: 1 + `Structure`, 2 `AutomationTarget` (`:388-395`, #1335, #1566, #1631, #1645), 3 changed controls, 4 + unresolvable effect params, 5 lowering. D1's new check runs after step 2 and before step 3. + `LiveRebuild` is `crates/host-core/src/prepare.rs:4818-4838`; `PlanStateInventory` `:728-789`; + `SuccessorBase` `:1287`; `across_withdrawn` `:1240`; the carry program `:2467-2486` and its + install `:3147-3150`; the strip join `join_strips` `:5087-5160` (`StripJoin` `:5031`). + `CellState` is `crates/automation/src/events.rs:91-155`. Tests: `fader_ride` + `crates/host-core/tests/live_delta.rs:1103`, `model_only_edits` `:1282-1303`, + `model_only_edits_are_live_with_no_records` `:1307`, `eq_automation` `:3232`; the C ABI twin + `model_only_edits` `crates/capi/src/runtime/live_tests.rs:2356-2420` (the `control_smoothing` + case now comes before the upsert, #1639) and `model_only_edits_commit_without_a_plan_rebuild` + `:2430`. Docs: `crates/capi/include/miso_engine_v1.h:46-47`, + `docs/CONTROL_PROTOCOL_SEMANTICS.md:21` (the "#1260" sentence), + `docs/C_ABI_V1_QUALIFICATION.md:623-630`. +- **Landed deps and what they give.** #1645 (step 2 route), #1637 (`CellState`, `reposition`, + `hold_until`; no seeding method yet), #1638 (the timed fader stage), #1639 (the jump-length cell; + a successor seeds it from its own model, `a_successor_prepared_with_a_control_smoothing_edit_holds_the_new_lengths`), + #1277/#1280 (the strip join and carry retarget), #1312 (cells; `LatestCell` is generic, so no + new cell form), #1309 (`commit_live_once`, `crates/control-plane/src/control.rs:2905`: any + classifier `Err` is `LiveCommit::Rebuild`, so D1 needs no control-plane edit), #1641 (every plan + carries the timeline), #1642/#1643 (session seek: `miso_engine_v1_session_seek`, + `SessionState::session_seek`, gate 2's reference), #1698 (`across_withdrawn`), #1562b (the + browser rebuild path; on a page without cross-origin isolation it is a blocking rebuild, counted). +- **Departure 1: two classifier paths.** #1612/#1613 added a fast path (`classify_rows`, + `:557-734`, and the in-place `classify_live_staged`) for transactions of `SetControl`s. It + assumes "the edits leave the automation table as committed" (`:576-577`). D1 holds there today. + It stops holding when #1650 makes `SET_CONTROL fader_db` write an automation entry's offset; see + departure 3. +- **Departure 2: D3 goes in the shared level lowering.** VCA and own-fader records come from + `LevelDiff::level_records` (`live_delta.rs:1015-1019`), implemented by `EffectiveDiff` + (`prepare.rs:4526`, the full path and the strip join) and `LocalDiff` (`live_delta.rs:1246`, the + fast path). D3's "no `FaderDb` for an automated lane" must be applied there, once, so the full + path, the fast path and a successor's carry retarget (`join_strips`, #1277 D5) all obey it. A + suppressed lane gives no record, not an error, so the stage still joins as `Same` and carries. + No #1277 predicate edit is needed: an automation change moves no static level value. +- **Departure 3: D1's comparison excludes the offset.** #1650 stores the hand offset in the entry + and makes an offset-only change `live` (#1650 D5). D1 compares each rendered entry's `id`, + `target` and `segments` (canonical bytes), never its `offset`. +- **Departure 4: the adoption rule compares the composed gain.** A rebuild can also change `S` + (a VCA or hand-offset edit in the same transaction). D2's adoption test is: if + `checked_fader_gain(vca_compose_db(curve(t), S_successor))` differs in bits from the carried + stage gain target, jump. A comparison on the curve value alone would keep the old `S` heard. + The same holds for gained and release cells. +- **Departure 5: D3's gate cases move.** Under #1650 the static fallback of an automated fader lane + is no control value (the row reads the offset), so a static change by any edit other than + `SET_CONTROL` is step 1 `Structure`, a rebuild that seeds the release value. Gate 1's static + `left_db` case and gate 5 are therefore not this slice's: the fader hand move is #1650 gate 2, the + VCA case is #1649 gate 5, the `model_only` case is #1657's. Gate 1 keeps the add, change, remove, + mute-row, combined and `linear`-unit cases. Under #1650's binding the combined case (a static + change and an automation change) can be step 1 `Structure` before D1's check; it asserts a + rebuild, not the variant. A test written here and superseded by #1649 or #1650 in the same push + is not written. +- **Inventory and supersession.** The cell-address rows join `PlanStateInventory` and must pass + through `across_withdrawn` unchanged, so a superseding candidate carries cell state from P0 as + #1698 carries every other owner. +- **The #1639 gap closes here.** A fader automation edit is a rebuild, and the successor seeds its + jump-length cell from its own model, so an entry added after a `control_smoothing` change that + was not written (no reading entry then) reads the new lengths from its first block. The batch + 13b gate stands, in `crates/capi/src/runtime/live_tests.rs`. +- **`automation_row_renders`** is one function in `crates/controls/src/automation.rs`. #1646's + preparation and this slice's classifier both call it; whichever lands first adds it. +- **Authorized paths, as they now must read:** + - `crates/host-core/src/live_delta.rs`, `crates/host-core/tests/live_delta.rs` + - `crates/host-core/src/prepare.rs` (`LiveRebuild::Automation`, the inventory cell rows, the + join, `across_withdrawn`, release cells, the `level_records` suppression in `EffectiveDiff`) + - `crates/controls/src/automation.rs`, `crates/controls/src/lib.rs` (`automation_row_renders`, + `automated_cell` and their exports), `crates/controls/tests/automation.rs` + - `crates/automation/src/events.rs` (one seeding method, batch 13b amendment) + - `crates/builtins-compiler/src/lib.rs` (the fader and fused bank processors' carry of cell event + state), `crates/graph/src/lib.rs`, `crates/graph/src/runtime.rs` (the strip move only) + - `crates/host-core/tests/successor_swap.rs`, `crates/capi/src/runtime/live_tests.rs`, + `hosts/host-web/tests/control_parity.rs` + - `crates/capi/include/miso_engine_v1.h` (comment), `docs/CONTROL_PROTOCOL_SEMANTICS.md`, + `docs/C_ABI_V1_QUALIFICATION.md` + - No path under `crates/engine/src/realtime/`. `crates/control-plane/src/` drops out (no edit + needed); `hosts/host-web/src/tests.rs` drops out. +- **Gates' test files.** Gate 1 `crates/host-core/tests/live_delta.rs`; gates 2-4 and the #1639 + gap gate `crates/capi/src/runtime/live_tests.rs`; the browser leg of gate 2 + `hosts/host-web/tests/control_parity.rs`; gate 6 `crates/host-core/tests/successor_swap.rs`. + `bash scripts/check-realtime-policy.sh` no longer exists: use + `cargo build --locked --release -p realtime-policy && ./target/release/realtime_policy`. +- **Size and split.** About 16-20 implementer hours: D1 and the docs 3 h, the suppression hook 1 h, + D2's carry (rows, join, reposition, composed-gain jump, gained and release cells, the held-ramp + obligation, `across_withdrawn`) 8-10 h, tests 5 h. Split into two attempt-bounded slices in the + same push: **#1648a** classification (D1, D3's hook and predicate, D4, gate 1, the #1639 gap + gate) and **#1648b** carry (D2 and the batch 13b items, gates 2-4 and 6). diff --git a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md index cfd9bc990..6f62a9410 100644 --- a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md @@ -216,3 +216,74 @@ Checked against `12d4a4f9d`. - **VCA automation renders in #1655** (root's F20 ruling, README "Root's rulings (2026-10-14)"). It extends this slice's offsets cell and write: automated VCA curves add to `S` at each event. This slice's non-goal stands for itself. + +## Amendment (controls coordinator, batch 17 spec check, 2026-10-16) + +Checked against `31fa33fee`. This amendment overrides the filed text and the earlier amendments +where they differ. + +- **Anchors moved.** `vca_effective_db` `crates/session/src/vca.rs:26` and `VcaSum` `:37-66` + (still member first until #1646 D6). `effective_strip_faders` is gone: `controls::effective` + (`crates/controls/src/effective.rs:399`, per strip `effective_strip` `:311`) composes every + effective fader. The order test is `crates/controls/tests/composition.rs`; + `crates/session/tests/vca_composition.rs` is gone. #1247's "strip step" is + `LevelDiff::level_records` (`crates/host-core/src/live_delta.rs:1015-1019`), implemented by + `EffectiveDiff` (`crates/host-core/src/prepare.rs:4449-4540`, full path and strip join) and + `LocalDiff` (`live_delta.rs:1085`, `:1246`, the fast path of #1612/#1613). The record length + rule is `LevelRules::lengths` (`prepare.rs:4310`). The live-ramp row is + `LiveRampRow::VcaOffset` (`crates/effect-contract/src/control.rs:77-78`); `VcaFader` does not + exist. Fader cells: `FaderCells` `crates/builtins-compiler/src/lib.rs:298`, `FaderReader` + `:396`, `FaderBankProcessor` `:1519`, `FaderMatrixBankProcessor` `:1831` (the fused bank also + holds the fader). `scripts/check-bench-policy.sh`'s host ban is `:257-283`. +- **Landed deps and what they give.** #1247 is the effective diff (VCA rides, membership, nested + VCAs, added and removed VCAs are live today; step 1 masks the VCAs whole). #1312: `LatestCell` + is generic (`crates/engine/src/realtime/latest_cell.rs:590`), so a three-word cell needs no + realtime edit. #1309: the commit writes cells in `commit_live_once` + (`crates/control-plane/src/control.rs:2905`), to the newest epoch's producers. #1639: the + jump-length cell. #1562b: the browser commits the same way. +- **Departure 1: one length rule.** D4's ramp word is the lane's fader length from + `LevelRules::lengths` (own edit: row `Fader`; else the first reaching VCA whose offset changed: + row `VcaOffset` with that edit's length; else `VcaOffset` with no edit length). So an automated + member and a plain member of one VCA edit take one length by construction. +- **Departure 2: both classifier paths.** The `S` comparison and the offsets-cell record go in the + shared `level_records` (both `EffectiveDiff` and `LocalDiff`), in the branch #1648 adds for + automated lanes. A fast-path-only or full-path-only rule would make the result depend on the + edit's shape. +- **Departure 3: `S` has one author.** `S` per strip lane is computed in `controls::effective` + (C7), beside the effective fader, so preparation (#1646), this slice's classifier and #1650's + offset read one function. `EffectiveStrip` gains the lane's `S` (`f64`), written by + `effective_into` with no allocation. If #1646 adds it, this slice only reads it. +- **Departure 4: a new record in the strip-level family.** `LiveStripRecords` gains the + offsets-cell write (`S` and the ramp word per lane); `FaderCells` (or a sibling writer on the same + lane) gains the three-word cell writer that #1646 creates. This stays in decision 17's strip + level family; no family is added. +- **Departure 5: rebuilds.** A rebuild seeds `S` from the successor's model (#1646), and #1648's + adoption rule compares the composed gain (its amendment, departure 4), so a VCA edit inside a + structural transaction is heard at adoption. This slice adds nothing to the carry. +- **VCA automation** renders in #1655 (batch 10 amendment); the non-goal "out of scope by decision + 13" is superseded. +- **Authorized paths, as they now must read:** + - `crates/host-core/src/live_delta.rs` (the `LiveStripRecords` field, `LocalDiff`'s automated-lane + branch), `crates/host-core/src/prepare.rs` (`EffectiveDiff`'s automated-lane branch only) + - `crates/controls/src/effective.rs` (the lane's `S`, only if #1646 did not add it), + `crates/controls/tests/effective.rs` + - `crates/control-plane/src/control.rs` (the offsets-cell write in `commit_live_once`) + - `crates/builtins-compiler/src/lib.rs` (the offsets-cell writer, the fader and fused banks' + dirty read at block entry and the jump) + - `crates/host-core/tests/live_delta.rs`, `crates/host-core/tests/vca.rs`, + `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, + `crates/control-plane/tests/vca_automation_realtime.rs` (new) + - `crates/control-plane/Cargo.toml` and `Cargo.lock` drop out: control-plane already has the + `bench-support` dev-dependency (`crates/control-plane/Cargo.toml:29`). + - No path under `crates/engine/src/realtime/`. The render read in builtins-compiler sits in + realtime-policy regions; their count may move. +- **Gates' test files.** Gates 1-4: `crates/capi/src/runtime/live_tests.rs` and + `hosts/host-web/tests/control_parity.rs` (not `hosts/host-web/src/tests.rs`); gate 5: + `crates/host-core/tests/live_delta.rs`; gate 6: `crates/control-plane/tests/vca_automation_realtime.rs`. + Commands: `cargo test --locked -p controls` replaces `-p session` for the order test; + `bash scripts/check-realtime-policy.sh` is replaced by + `cargo build --locked --release -p realtime-policy && ./target/release/realtime_policy`. +- **Size.** About 12-15 implementer hours: classifier branch on both paths and `S` 3 h, the record + and the control-plane write 2 h, the cell writer, the dirty read and the jump on both bank kinds + 3-4 h, tests (both hosts, the nested-order case, realtime binary) 5 h. One attempt; no split + (the classifier and the render halves are not useful apart). diff --git a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md index 94085e019..0b9d04e07 100644 --- a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md +++ b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md @@ -232,3 +232,105 @@ owner's answer covers level controls, and the DAW trim modes include sends. control too; #1655 adds it to `takes_offset` (a reading of root's ruling for root to confirm). - **Gate 1** gains: an entry on a send `gain_db` whose two offset fields differ is refused `control.automation.offset`; a non-zero offset on a send matrix row is refused. + +## Amendment (controls coordinator, batch 17 spec check, 2026-10-16) + +Checked against `31fa33fee`. This amendment overrides the filed text and the earlier amendments +where they differ. + +- **Anchors moved.** `Automation` `crates/session/src/model.rs:977-985`; the automation walk keys + `crates/session/src/visit.rs:143`, walk `:299`; `parse_automation` + `crates/session/src/parse.rs:1569`; wire message `automation` + `crates/protocol/src/schema.rs:1321-1333`, encode `crates/protocol/src/session_wire.rs:1072-1080`; + the edits `crates/protocol/src/model.rs:81-89`, `:315-327`. `automation_diagnostics` + `crates/controls/src/automation.rs:89`; its callers `crates/host-core/src/prepare.rs:2529`, + `crates/host-core/src/live_delta.rs:388-395` (step 2), `tools/session-validator/src/lib.rs:396`. + Builtin rows: `read` and `write` `crates/controls/src/builtin_rows.rs:121-188`; the model write + `crates/controls/src/registry.rs:518-560`. `trim_db` `crates/builtins/src/lib.rs:514-540`, + `fader_db` `:596-619`. `control_entry` `crates/protocol/src/schema.rs:407-441` (31 + `automatable`, 33 `active`, 34 `value`; 35 is free); the catalog row + `crates/host-core/src/control_provider.rs` (`automatable` `:1969`, `:2072`), the structure epoch + `:441-453`; the SDK decode `sdk/src/core/control-list.ts:275`, `:586`. Schema hash + `crates/conformance/src/protocol_corpus.rs:928`. +- **Landed deps.** #1566 (address target, row rule), #1568 (equal keys) and #1569 (generated SDK + key tables, `sdk/src/generated/session-keys.ts` from `sdk/assets/miso-engine-v1-session-keys.json`) + have landed, so D1's "whichever lands first" is moot: regenerate. #1631/#1632 (the row rule on + activity changes; `follows_mute` and `matrix_mode` not automatable). #1612/#1613/#1620 (the fast + and in-place `SetControl` paths). #1562b (the browser rebuild path). +- **Departure 1: the binding needs the model; the in-place path does not have it.** `builtin_rows` + reads and writes through a `StripRef`/`StripMut`, and the in-place path (#1613, + `crates/protocol/src/model.rs:1435-1500`) applies `SET_CONTROL` to an owners model + (`controls::OwnerEdits`, `crates/controls/src/owner_edits.rs`) whose `automation` is empty and + whose `OwnerSlots` (`crates/session/src/in_place.rs:30`) hold no automation entry. D3 as written + would make the in-place path see "not automated" and write the static field, and the fast + classifier would lower a `FaderDb` onto the automated lane: an ack of a value the curve then + overrides. **Ruling question R17-1** below. +- **Departure 2: the static fallback is no control value on an automated lane.** Because the row + reads the offset there, `controls::mask_values` copies the offset, and a change of the static + field by any edit is step 1 `Structure` (a rebuild that seeds the release value, #1648). This is + D3's intent ("not reachable by `SET_CONTROL`"); record it in `docs/SESSION_SCHEMA_V1.md`. +- **Departure 3: #1648 D1 excludes the offset** (#1648's amendment, departure 3), so an offset-only + `UpsertAutomation` stays `live` as D5 says. +- **Departure 4: D6 is no render change.** Render composes with `S` from the cell (#1646, #1649). + This slice changes who computes `S`: `controls::effective` adds the lane's own offset first, then + the reaching VCA offsets (#1649's amendment, departure 3), and #1646's preparation seeds it. So + `crates/builtins-compiler/src/lib.rs` drops out of this slice. +- **Trim.** Trim automation renders in #1658, which is not in this batch. This slice can close the + trim part as grammar and row rule only: a trim entry carries an offset that is checked and stored + and is inert (D3's last bullet); `SET_CONTROL trim_db` keeps writing the static field until #1658 + adds trim to `automation_row_renders`. No trim gate is needed here; #1658 owns the flip and its + gates. The same holds for the send gain (#1654). +- **Split (recommended).** **#1650a** D1, D2, the docs and gate 1: the field, the grammar, the wire, + the SDK, the fixtures and the row rule. It is inert, so it can land in its own push before + #1646, which then seeds `S` from a field that exists. **#1650b** D3-D5, the list flag and gates + 2-6, in the R1 push with #1646-#1649 (the "no middle state" hazard applies to #1650b only). +- **Authorized paths, as they now must read:** + - #1650a: `crates/session/src/{model.rs,parse.rs,visit.rs,lib.rs}`, `crates/session/tests/**`; + `crates/protocol/src/{schema.rs,session_wire.rs,model.rs}`, `crates/protocol/src/session_wire/**` + and their tests; `crates/controls/src/automation.rs`, `crates/controls/tests/automation.rs`; + `crates/conformance/src/protocol_corpus.rs` (re-pin); `fixtures/session/v1/canonical.json`, + `fixtures/session/v1/builtins-automation.json`, `fixtures/session-canonical/**`; + `.claude/skills/author-session/{SKILL.md,worked-session.json}`; + `sdk/assets/miso-engine-v1-session-keys.json`, `sdk/src/generated/*` (regenerated), + `sdk/src/core/session.ts`, `sdk/src/internal/session-json.ts`, `sdk/test/**`; + `docs/SESSION_SCHEMA_V1.md`, `docs/session-v1.schema.json`. + - #1650b: `crates/controls/src/{builtin_rows.rs,registry.rs,strip.rs,mask.rs,diff.rs,effective.rs,view.rs,owner_edits.rs,lib.rs}` + and `crates/controls/tests/**`; `crates/session/src/in_place.rs` (if R17-1 = A); + `crates/protocol/src/model.rs` (`prepare_in_place`), `crates/protocol/src/message_wire.rs` + and `crates/protocol/src/schema.rs` (field 35); `crates/host-core/src/control_provider.rs` (the + `automated` row field); `crates/host-core/src/live_delta.rs`, + `crates/host-core/tests/live_delta.rs`; `sdk/src/core/control-list.ts`, + `sdk/src/generated/*`, `sdk/test/**`; `crates/capi/src/runtime/live_tests.rs`, + `hosts/host-web/tests/control_parity.rs`, `crates/control-plane/tests/` (gate 6); + `docs/CONTROL_PROTOCOL_REGISTRY.md`. + - No path under `crates/engine/src/realtime/`; no render file. +- **Gates' test files.** Gate 1 `crates/session/tests/` and `crates/controls/tests/automation.rs` + (the wire round trip in `crates/protocol`'s tests); gates 2 and 4 + `hosts/host-web/tests/control_parity.rs` (both hosts) and `crates/capi/src/runtime/live_tests.rs`; + gate 3 `crates/capi/src/runtime/live_tests.rs`; gate 5 `crates/host-core` (control provider) and + `control_parity.rs`; gate 6 `crates/control-plane/tests/vca_automation_realtime.rs` (shared with + #1649). Add to gate 2: the same `SET_CONTROL` through the in-place path (a transaction of only + that edit) gives the same reply and bits as through the full path. `./target/release/realtime_policy` + needs `cargo build --locked --release -p realtime-policy` first. +- **Size.** #1650a about 8-10 implementer hours (grammar, wire, SDK regeneration, fixtures, row + rule, docs). #1650b about 10-14 hours with R17-1 = A (binding with model scope, the in-place + owners, the list flag on both sides, the classifier, tests on both hosts); about 7-9 with + R17-1 = B. + +### Ruling question R17-1: how a hand move on an automated lane reaches the offset on every path + +- **A. The automation entry is an owner of the in-place edit.** `OwnerEdits` clones, with the + strip, the rendered entries that drive the written lane; `OwnerSlots` and `ModelView` carry them; + the builtin row's read and write for rows 2 and 5 take the entry. Keeps D1, keeps the in-place + cost (independent of the session's size) for the most frequent hand move, a fan riding a fader. + Widest edit (`session::in_place`, `controls` view, owner edits, effective inputs). +- **B. The in-place path declines it.** A `controls` check on the committed model declines a + `fader_db` or `trim_db` write on a lane a rendered entry drives; the full path (model clone, + canonical JSON) commits it. Smallest edit; every hand move on an automated lane then pays the + full-path cost that #1613 and #1620 removed for large sessions. +- **C. The offset lives on the strip** (`DualMonoFader` and `ChannelBuiltins` gain offset fields). + In-place works unchanged, but D1 changes: `RemoveAutomation` must reset the offset and the + binding still needs the table to choose offset or static. +- **Recommendation: A.** It is the correct long-term shape (no path-dependent cost, no D1 change) + and keeps decision 17 C3's one binding place in `controls`. B would be a shortcut that a later + issue must remove. diff --git a/.github/ISSUE_SPECS/1688-report-the-plan-latency-on-the-browser.md b/.github/ISSUE_SPECS/1688-report-the-plan-latency-on-the-browser.md deleted file mode 100644 index 1ed7dd9ca..000000000 --- a/.github/ISSUE_SPECS/1688-report-the-plan-latency-on-the-browser.md +++ /dev/null @@ -1,199 +0,0 @@ -# Report the plan latency on the browser - -One of the one-host differences of decision 17 C13 (`docs/rulings/one-control-abstraction-2026-10-13.md`). -Code anchors verified on `main` at `e2f6cfb24`. Issue number: assigned at filing (written here -as #1688). Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch review. - -## Product outcome - -A browser app reads the running plan's output latency and output tail, as a C ABI app does. After -a rebuild is adopted, the app reads the new plan's values. Both hosts read them from one core -function, so they always agree. An app that aligns a meter, a playhead or a recording to the -output can do it on the browser. - -## Context - -- **The core row.** Host-core reports a plan's latency as `HostPrepareReport::latency_samples` - (`crates/host-core/src/prepare.rs:273`, filled at `:3134` from the output arrival). The control - plane copies it into `PlanResources::latency_samples` (`crates/control-plane/src/compile.rs:1003`; - the field is `crates/control-plane/src/adapter.rs:104`, beside `output_tail: TailSamples` at - `:106`). The control plane keeps one row per plan epoch. -- **The core read.** `PlanQueries::resources()` (`crates/control-plane/src/plan.rs:97`) copies the - row of the active plan epoch (`active_resources`, `:49`). It runs on any thread, beside render. -- **The C ABI.** `miso_engine_v1_plan_resources` (`crates/capi/src/ffi.rs:930`) writes that row - as `PlanResourceReport`: `latency_samples`, `tail_kind`, `tail_samples` - (`crates/capi/include/miso_engine_v1.h:366-368`, `crates/capi/src/abi.rs:283`). The tail - encoding is in the capi adapter: `From for PlanResourceReport` - (`crates/capi/src/runtime/mod.rs:217-222`), with `TAIL_FINITE` 0 and `TAIL_INFINITE` 1 - (`crates/capi/src/abi.rs:37-39`). -- **The browser today.** The browser runs the same control plane (#1381a, - `hosts/host-web/src/lib.rs:6-16`), and its adapter's row is `PlanResources` itself - (`WebAdapter`, `lib.rs:5243-5244`). It holds `queries: PlanQueries` (`lib.rs:1514`). - But it exports no latency and no tail. Its `WebResourceReport` (`lib.rs:1216`, exported by - `miso_engine_web_v1_resource_ptr`, `hosts/host-web/src/ffi.rs:3450`) is a byte projection made - once at boot (`lib.rs:2220`) and is never refreshed. It has no latency field. -- **The pattern to follow.** The watermark is a core read the browser copies into a fixed - record: `read_watermark` (`lib.rs:2522-2544`) fills `WebPlanWatermark` (`lib.rs:1189`) from - `ready.queries.watermark()`. The exports are `miso_engine_web_v1_watermark_read` and - `miso_engine_web_v1_watermark_ptr` (`ffi.rs:3434`, `:3442`; #1381a D5). -- **Parity.** `hosts/host-web/tests/control_parity.rs` plays one script through both hosts. The - script already holds a rebuild that moves the plan's latency: a strip's `delay_samples` - (`control_parity.rs:226`, `:727`). -- **Floors.** *Keep every node's latency from dropping during playback* (#1285) makes the - reported latency the floored output arrival (its D4). Its batch-15 amendment notes that the - browser reports no latency, so its gate 3 covers the C ABI only. - -## Decisions frozen for this slice - -- **D1. One core function.** Both hosts read the latency and tail through - `PlanQueries::resources()`. The tail's two-word encoding moves into the control plane as one - function, `control_plane::tail_words(TailSamples) -> (u64, u64)`, with the two kind constants - beside it. The capi adapter's `From` impl calls it, and the C ABI's `TAIL_FINITE` and - `TAIL_INFINITE` keep their values. No new latency logic. -- **D2. The browser form.** `miso_engine_web_v1_plan_latency_read(handle) -> u32` copies the - active row's `latency_samples` and the D1 tail words into a fixed record, `WebPlanLatency`: - `struct_size: u32`, `reserved0: u32`, `latency_samples: u64`, `tail_kind: u64`, - `tail_samples: u64`, `reserved: [u64; 4]` (56 bytes). `miso_engine_web_v1_plan_latency_ptr(handle) - -> u32` returns the record's stable address. A host that is not ready returns - `RESULT_WRONG_STATE` and leaves the record alone, as `read_watermark` does. The read runs where - the plan queries are (the worklet's message handler in `single` mode; #1332 moves it with the - rest of the control half). -- **D3. Mirrors.** Both exports and the record's offsets join every export list and layout - mirror, as the watermark's did. -- **D4. Parity row.** `control_parity.rs` compares, after every scripted edit and at the end, the - browser record's three words with the C ABI report's `latency_samples`, `tail_kind` and - `tail_samples` for the same plan. -- **D5. The acked-batch question.** This is a read. It acks nothing and drops nothing. - -## Deliverables - -1. D1 in `crates/control-plane/src/` and `crates/capi/src/runtime/mod.rs`. -2. D2 in `hosts/host-web/src/lib.rs` and `hosts/host-web/src/ffi.rs`, with native tests. -3. D3 in the checkers, the generator and the generated SDK files. -4. D4 in `hosts/host-web/tests/control_parity.rs`. - -## Authorized paths - -- `crates/control-plane/src/plan.rs` or `crates/control-plane/src/adapter.rs` (D1), - `crates/control-plane/src/lib.rs` (re-export only), `crates/capi/src/runtime/mod.rs` (the `From` - impl only), `crates/capi/src/abi.rs` (the constants' source only, values unchanged) -- `hosts/host-web/src/lib.rs`, `hosts/host-web/src/ffi.rs`, `hosts/host-web/src/tests.rs`, - `hosts/host-web/tests/control_parity.rs` -- `scripts/check-web-audioworklet.sh` (export list only), `scripts/check-abi-layout-v1.py` and - `scripts/fixtures/abi-layout-v1-self-test.json`, `tools/parameter-metadata/src/abi_layout.rs`, - `sdk/assets/miso-engine-v1-abi-layout.json`, `sdk/src/generated/abi.ts`, - `scripts/check-sdk-generated.sh`, `scripts/test-web-audioworklet.mjs` (the mock's export list - only) - -## Non-goals - -- No SDK API. A later issue gives the SDK a `planLatency()` call on this export. -- No change to how latency is computed (#1285 owns the floors) or to the C ABI's report. -- No refresh of `WebResourceReport`. It stays a boot projection. -- The other one-host differences of C13. - -## Objective gates - -1. **Parity (D4).** In `control_parity.rs`, at each launch rate the test already runs, the - browser record equals the C ABI report's three words after every scripted edit and at the - end. The `delay_samples` edit changes `latency_samples` on both hosts; the test asserts that - the value moved, so the row is not vacuous. -2. **The read (native, `hosts/host-web/src/tests.rs`).** Before boot completes, the read returns - `RESULT_WRONG_STATE` and the record keeps `struct_size` 0. After boot, it returns OK and the - record holds the boot plan's latency and tail. After a rebuild that adds a true-peak limiter - insert, the record keeps the old values until the adoption block, and holds the new plan's - latency from the first read after it. -3. **Mirrors.** `python3 -B scripts/check-abi-layout-v1.py` and its self-test pass, and fail if a - list omits an export or a record offset. -4. **Commands:** - - `cargo test --locked -p host-web --features host-web/test-support` - - `cargo test --locked -p capi`, `cargo test --locked -p control-plane --features control-plane/test-support` - - `mkdir -p target/ci/qualification-artifacts target/ci/qualification-named-twin && bash scripts/build-web-audioworklet.sh --named-twin target/ci/qualification-named-twin target/ci/qualification-artifacts` - - `bash scripts/check-web-audioworklet.sh --without-metadata-regeneration target/ci/qualification-artifacts target/ci/qualification-named-twin/miso-engine-v1-audio-worklet.simd128.named.wasm` - - `bash scripts/test-web-audioworklet.sh`, `bash scripts/check-sdk-generated.sh target/ci/qualification-artifacts` - - `bash scripts/check-capi-abi.sh` - - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `cargo fmt --all -- --check`, `bash scripts/check-workspace-policy.sh` - -## Test value - -- Gate 1 turns red if the browser computes latency or the tail by logic of its own, reads a - stale plan's row, or encodes the tail differently from the C ABI. -- Gate 2 turns red if the read reports the candidate's latency before render adopts it, or keeps - the boot value after a rebuild (a boot-time copy like `WebResourceReport`). -- Gate 3 turns red if a mirror lets the SDK call a name the module lacks. - -## Dependencies - -- Decision 17 C13 lists "plan latency and tail" as a one-host difference for root to file. - This is that issue. -- *Keep every node's latency from dropping during playback* (#1285), ordering: land this after - #1285, so the parity row reads floored latencies. #1285 then needs no browser gate of its own; - this row covers the browser. -- #1381a has landed (the browser runs the control plane). #1332 (open) moves the read with the - control half; it is not a dependency. - -## Size - -Half a day. - -## Amendment (controls coordinator, batch 15a fold, 2026-10-16) - -- **D2's record size.** `WebPlanLatency` is 64 bytes, not 56: `struct_size` and `reserved0` (4 + 4), - `latency_samples`, `tail_kind` and `tail_samples` (3 x 8), `reserved: [u64; 4]` (32), with no - padding. D2's "(56 bytes)" reads "(64 bytes)". The ABI layout mirrors (D3) record 64. -- **Decision 17 C13.** The two exports are listed there (root, 2026-10-16) as a transport read of - the core's `PlanQueries::resources()` row, so they are not a C13 exception. - -## Decision record: #1688 (batch 15b) - -**Shipped.** - -- D1: `control_plane::tail_words(TailSamples) -> (u64, u64)` with `TAIL_FINITE` (0) and - `TAIL_INFINITE` (1), in `crates/control-plane/src/adapter.rs`, re-exported from `lib.rs`. The - capi adapter's `From` calls it, and capi's `TAIL_FINITE` and `TAIL_INFINITE` are - now defined as the control plane's constants (values unchanged). -- D2: `WebPlanLatency` (64 bytes, per the batch 15a amendment), `PLAN_LATENCY_BYTES`, - `AudioWorkletEngineHost::read_plan_latency` and `plan_latency`, and the exports - `miso_engine_web_v1_plan_latency_read` and `miso_engine_web_v1_plan_latency_ptr`. The read copies - `PlanQueries::resources()` and the `tail_words` encoding; host-web computes nothing. The record - is all zero until the first successful read. -- D3: mirrors in `tools/parameter-metadata/src/abi_layout.rs` (exports 102 -> 104, structure - `planLatency`), `scripts/check-abi-layout-v1.py` (exports, structure, field names, two new - self-test mutations), `scripts/check-web-audioworklet.sh` (export list), - `scripts/fixtures/abi-layout-v1-self-test.json`, `sdk/assets/miso-engine-v1-abi-layout.json` and - `sdk/src/generated/abi.ts` (regenerated by `sdk/codegen/assets.mjs` and `generate.mjs`). No - change to `scripts/check-sdk-generated.sh` or the mock's export list was needed. -- D4: `control_parity.rs` compares the browser record with `miso_engine_v1_plan_resources` before - and after every block of the scripted run, at each launch rate (this covers the boot plan, every - plain successor of the script's rebuilds and the end). - -**Departures.** - -- The spec says a strip's `delay_samples` moves the plan latency. It does not: a track delay - shifts the output and leaves the PDC report alone (`graph-compiler` `track_delay.rs` P2-3). The - moving rebuild is four added true-peak limiter inserts on `t1` (the added latency makes `t1` the - longest path); `a_rebuild_that_adds_latency_moves_the_plan_latency_alike_on_both_hosts` asserts - the value moved. -- "Before boot completes" has no host object: a handle that names no booted host returns - `RESULT_INVALID_ARGUMENT` from the export. The `WRONG_STATE` and zero-record case is a host - whose state is not ready (the native test sets `STATE_FAILED`). -- Mechanical: `crates/capi/src/runtime/tests.rs` calls `tail_words` in two places and spells - `crate::TAIL_INFINITE` in one. - -**Gates.** - -- Gate 1: `control_parity`: `both_hosts_give_the_same_result_for_the_same_control_edits` (the - record equals the C ABI report after every block, every launch rate), - `a_rebuild_that_adds_latency_moves_the_plan_latency_alike_on_both_hosts` (pending values stay - until the adopting block, then both move, equal) and - `removing_a_limited_track_keeps_the_plan_latency_alike_on_both_hosts` (#1285 floors: both hosts - report the same, unchanged latency after the swap, and a fresh compile of the committed model - reports less). Result: 17 of 17 pass. -- Gate 2: `tests::structural_apply::the_plan_latency_record_follows_the_active_plan` (host-web - lib): not ready leaves the record zero, boot copy of the core row, old value while the limiter - rebuild waits, equal to a fresh boot after adoption. Passes. -- Gate 3: `check-abi-layout-v1.py --self-test` (32 mutations caught) and the artifact check in - `check-web-audioworklet.sh` pass against a built module, so the module exports both names. -- `check-browser-expected-resources.py` passes with no re-pin (bridge retained bytes 1549094 of - 1667648). diff --git a/.github/ISSUE_SPECS/1698-carry-an-owner-a-superseding-candidate-keeps-unchanged-from-p0.md b/.github/ISSUE_SPECS/1698-carry-an-owner-a-superseding-candidate-keeps-unchanged-from-p0.md deleted file mode 100644 index 4843bdafd..000000000 --- a/.github/ISSUE_SPECS/1698-carry-an-owner-a-superseding-candidate-keeps-unchanged-from-p0.md +++ /dev/null @@ -1,216 +0,0 @@ -# Carry an owner a superseding candidate keeps unchanged from P0 from P0's state - -Successor of #1344 (prepare a successor across a withdrawn candidate plan) and #1310 (supersede an -unadopted candidate plan by compare and swap), both batch 15a. Found in the batch 15a review; root -ruling 2026-10-16. Code anchors verified on the batch 15a fold branch `codex/controls-b15a-fold` -at `804fe6939` (committed tree; the branch has uncommitted edits in `control.rs` while this is -written, so the implementer re-reads the line numbers). Issue number: assigned at filing (written -here as #1698). Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch -review. - -## Product outcome - -An edit followed quickly by its undo is not audible. When the running plan P0 has a committed -candidate A that render has not adopted, and a later transaction commits B that puts an owner X (a -strip, an effect, a meter, an observation tap or another owner of plan state) back to its P0 form, -B carries X from P0, as a direct P0 -> B swap would. Before this issue X started fresh in B -whenever A restarted or removed it. Sources stay under the host contract (amended by the -coordinator ruling of 2026-10-16, confirmed by root; see the decision record): a source A added, -redeclared or removed starts fresh in B unless B keeps it as A declares it. - -## Context - -- **The defect.** #1344 D2 prepares B with `SuccessorBase { inventory: A.carried_base, committed: - P0's committed model }`. `carried_base` is P0's inventory restricted to the rows A's installed - hand-over reads (`PlanStateInventory::restricted_to`, `crates/host-core/src/prepare.rs:1086`; - `PreparedHost::carried_base`, `:689`). So an owner carries from P0 only if A carried it. - Anything A restarted, added or removed is fresh in B (the doc at `prepare.rs:1188`-`:1196` - states this as the rule). For an X that A restarted or removed and B reverts to its P0 form, - that is wrong: P0 still holds X's state, because A was never adopted, and the combined twin - (P0 -> B with one transaction) carries it. -- **Where B's base is built.** `SessionState`'s structural commit, `let base = if superseding` - (`crates/control-plane/src/control.rs:2044`), reads A's `carried_base` at `:2053` and P0's kept - model. -- **Where producers move.** After the commit, the superseding branch applies the checked donation - (`:2325`-`:2347`, #1344 D3: rings A created fresh that B keeps) and then - `candidate_provider.sources.adopt_persisting(&mut donor.sources)` (`:2349`) from A's set. After - the swap is published, `holder.sources.adopt_persisting(&mut donor.sources)` (`:2461`) returns - to P0's epoch every producer A holds that P0 holds vacant, before A drops. - `SourceControlSet::adopt_persisting` (`crates/host-core/src/source.rs:291`) fills each vacant - entry from the predecessor's entry of the same ID, keyed by ID, allocation-free. -- **Host submits between A's commit and B's swap.** A restarted X with a fresh ring. The host's - chunks for X after A's commit went to A's X ring, not P0's. If B carries X's ring from P0, A's - ring and its acked chunks drop with A, and P0's ring underruns for those frames. That breaks - the acked-batch rule (AGENTS.md: can an ack ever precede a drop?). -- **Tests to extend.** `crates/host-core/tests/withdrawn_successor.rs` (P0, A and B models at - `:147`, `:164`, `:178`; `twin_run`, `:306`; `backends`, `:184`) and - `crates/capi/src/runtime/supersede_tests.rs` - (`a_superseding_replacement_renders_as_one_combined_transaction`, `:157`; - `a_removed_sources_producer_returns_to_the_running_epoch`, `:554`). - -## Decisions frozen for this slice - -- **D1. No ack before a drop (root, 2026-10-16).** Every chunk a host submit acks for X between - A's commit and B's swap is either read by B or refused before its ack. No acked chunk drops - with A. The implementer proposes the mechanism (B reads A's X ring for those frames, or the - submit to a ring that a later supersession can drop is refused with typed backpressure before - its ack, or another shape) and the coordinator rules; the rule itself is not open. - -## Decisions to take (the implementer proposes, the coordinator rules) - -- **Amend #1344 D1 and D2.** The base for an owner or source that B keeps unchanged from P0 is - P0's inventory row, not A's restricted base. Decide whether `PreparedHost::carried_base` and - `PlanStateInventory::restricted_to` stay (for example as the base of what A changed and B keeps) - or are replaced by P0's inventory with a rule for the rows A changed. #1344's doc at - `prepare.rs:1188`-`:1196` is amended with the decision. -- **A P0 producer pass.** Before the donation pass (`control.rs:2325`-`:2347`), add one - `adopt_persisting`-shaped pass from P0's source set, so a reverted X takes P0's producer and - never A's fresh one. State its order against the donation and against the return pass - (`:2461`), and prove it allocation-free. -- **Where submits for X go between A's commit and B's swap**, under D1. - -## Deliverables - -1. The base and the producer pass in `crates/host-core/src/{prepare.rs,source.rs}` and - `crates/control-plane/src/control.rs`. -2. D1's mechanism, wherever the ruling places it. -3. The gates below; #1344's and #1310's spec text amended by appending, not rewritten. - -## Authorized paths - -- `crates/host-core/src/prepare.rs`, `crates/host-core/src/source.rs`, - `crates/host-core/tests/withdrawn_successor.rs` -- `crates/control-plane/src/control.rs` and its tests -- `crates/capi/src/runtime/supersede_tests.rs` -- The source ring's submit path (`crates/source/src/lib.rs`) only if the D1 ruling puts the - refusal there -- This spec file's decision record; appended amendments in #1344's and #1310's specs - -## Non-goals - -- No change to the donation of rings A created fresh that B also creates fresh (#1344 D3). -- No change to what a direct P0 -> successor swap carries. -- No new C export or protocol message. - -## Objective gates - -1. **Revert carries from P0.** A removes X, B reverts X; and A restarts X, B reverts X; for X a - strip owner and for X a source. X carries from P0: B's rendered blocks are bit-identical to the - combined twin's (P0 -> B with one transaction, fed the same chunks), at both lane widths - (`Backend::VECTOR`), in `crates/host-core/tests/withdrawn_successor.rs` and in the capi - supersede tests. Each case is red on the base before the fix (recorded in the PR). -2. **No acked chunk is lost (D1).** A host submits chunks for X between A's commit and B's swap. - Every submit either returns a typed refusal or is acked, and every acked chunk's frames are - read by B: B's output equals the twin fed exactly the acked chunks, and the count of acked - chunks no plan reads is 0. -3. **The swap block allocates nothing.** `allocations == 0` across B's adoption block, measured - with `bench_support::alloc`'s thread-scoped counters after warm-up. -4. **Commands:** `cargo test --locked -p host-core --all-features`, - `cargo test --locked -p control-plane --features control-plane/test-support`, - `cargo test --locked -p capi`, `cargo run --locked --release -q -p realtime-policy`; - clippy `-D warnings` and fmt on the touched crates; `bash scripts/check-workspace-policy.sh`. - -## Test value - -- Gate 1 turns red if a reverted owner or source starts fresh in B, or takes A's fresh producer. -- Gate 2 turns red if a chunk acked into A's ring drops with A. -- Gate 3 turns red if the producer pass or the D1 mechanism allocates at the swap. - -- **Lifts the batch 15a guard, reverted case only.** Batch 15a (fold 2, widened in fold 3 by root - ruling M-A of 2026-10-16) refuses with `BACKPRESSURE` a supersession B that leaves behind a ring - A created (A added or redeclared the source) whose producer has acked anything - (`host_core::drops_acked_fresh_source`, called in `SessionState::rebuild` right after - `check_donation`). #1698 lifts only case (a), where B declares X as P0 does, once D1's mechanism - lets B read A's acked chunks: that case leaves the guard and its rows in - `supersede_tests::a_supersession_that_would_drop_acked_pcm_on_a_fresh_source_is_backpressure` - and `control_parity::a_supersession_that_would_drop_acked_pcm_is_backpressure_alike_on_both_hosts` - are replaced by gate 2. Cases (b) B redeclares X a third way, (c) B removes X, and (d) A added X - and B removes or redeclares it stay refused until an issue gives B a way to read those chunks or - the owner rules that a removed source's chunks may drop. - -## Dependencies - -- #1344 and #1310 (batch 15a): the withdrawn-candidate base, the donation and the supersession. -- Root ruling 2026-10-16: this is the first K slice of batch 15b, ahead of the rest of the source - chain. - -## Size - -Half a day. - -## Decision record: #1698 (batch 15b) - -**Base decision (amends #1344 D1 and D2).** Across a withdrawn candidate A, B's base inventory is -P0's own inventory, the inventory of the epoch A succeeds (`kept_holder().inventory`), through -`PlanStateInventory::across_withdrawn(&A.inventory, A.plan)` (`crates/host-core/src/prepare.rs`). -Every owner row is P0's, so an owner B keeps unchanged from P0 carries from P0 as a direct P0 -> B -swap carries it, also when A restarted or removed it. `committed` stays P0's kept model. The only -filter is on sources: a P0 source row is kept only when A carries that source from P0 (A's plan -holds it vacant, `can_prepare_source_seek` false on A's index). The filter reads A's ring, not A's -model, so a chain stays right: a source a candidate restarted is fresh in it, whatever its -declaration. `PreparedHost::carried_base`, `PlanStateInventory::restricted_to`, the report row -`carried_base_retained_bytes`, `ProviderEpoch::carried_base`, the capi epoch row term, the -test-support views `test_only_inventory_rows` / `test_only_carry_read_keys`, and -`graph::installed_carry_reads` with `GraphCarryReads` (no production caller left) are removed. -The new base is built once per supersession on the control thread, before the commit; it is not -kept. - -**Why sources do not carry from P0.** The host contract, `docs/CONTROL_PROTOCOL_SEMANTICS.md:23`: -"a source whose declaration the transaction changed restarts the same way, in a new ring at -generation 1, frame 0, with the PCM accepted for it before the commit discarded with its plan, so -the host restarts its feed." Submits and seeks address the newest committed session -(`SessionState::submit_unserviced`, `newest_providers_mut`). After A's commit the host feeds A's -X; B declares X differently from A, so a host that follows the contract restarts X at generation -1, frame 0. P0's X producer requires P0's generation and next frame (`validate_submission_metadata` -in `crates/source/src/lib.rs`), so carrying P0's X would refuse that feed (`StaleGeneration` or -`NonContiguous`). The host could follow only through a new signal, which the spec's non-goals -forbid. B reading A's acked chunks (D1 option i) is also wrong: they are A's declaration's -content. So D1 needs no new mechanism: `host_core::drops_acked_fresh_source` stays unchanged for -all four cases (a)-(d), and the spec's line "Lifts the batch 15a guard, reverted case only" does -not hold. The guard's capi and parity rows stay. The producer passes are unchanged: every source B -prepares vacant is one A carried from P0, whose producer A's commit moved into A's set, so no P0 -producer pass is needed (the order stays: donation, `adopt_persisting` from A, the return pass -after publication). - -**Gates (the new list; gate 2 is dropped by the ruling).** -1. Owners carry from P0, bit-identical to the combined twin P0 -> B: - - `crates/host-core/tests/withdrawn_successor.rs` - `an_owner_a_restarts_or_removes_and_b_reverts_carries_from_p0` (A replaces `t1`'s insert, or - removes track `t3`; B reverts it and replaces `t2`'s; Simd8 and Simd4; `restarted_strips()` - is `[t2]`; 16 blocks equal, zero allocator calls per block). - - capi `supersede_tests::an_owner_the_candidate_restarts_and_the_replacement_reverts_carries_from_p0` - and `..._removes_and_the_replacement_restores_carries_from_p0` (9 blocks equal the combined - transaction; build target's lane width only, as every capi test). - - Red on the base before the fix (recorded here): host-core restart case `restarted_strips` - `["t1", "t2"]` / `["t2", "t3"]`, and bits differ at block 0 in both cases at both widths; - capi both cases differ at block 1. -2. Sources stay as before: `a_source_b_reverts_is_fresh_and_a_source_a_carries_still_carries` (A - redeclares or removes `b`, B reverts it: B's `b` owns a fresh ring and takes generation 1 at - frame 0; `a`, carried by A, is vacant in B and its producer moves from A). Red with the source - filter removed (P0's full inventory): the `b` freshness assert. -3. Tap across a restart: host-web `a_subscription_on_a_candidate_that_restarted_the_tap_publishes_ - on_a_replacement_that_reverts_it` (A changes `comp`'s link mode, a subscription is acked - against A, B puts `comp` back; B's tables hold the subscription and windows publish). Red with - `observation_forwards` emptied (B now carries P0's unarmed tap). No spectrum test: a spectrum - capture carries by target and channels only, so A can restart one only by removing its target, - and the browser refuses that preparation (`RESULT_COMPILE_REJECTED`, probe not kept), so the - pairing B gets is P0's in both the old and the new base. -4. Gate 3 (allocations) runs unchanged: every rendered block in the host-core tests above asserts - zero allocator calls. -5. Deleted, superseded: `the_carried_base_holds_exactly_the_rows_the_hand_over_reads` and its - rich-session helpers. Gate 1's and gate 3's `installed_carry_reads` asserts became - `can_prepare_source_seek` asserts on B's index. - -**Measured.** Reference session `capi_retained_bytes` 168,810 -> 167,514 (x86-64, -1,296 = one -216-byte `Option` in each of six provider epochs); the 175,296 ceiling stays. -No ABI change: the row was a control-plane epoch term, not a header field. -Amendment (batch 15b fold): after #1285's `input_floors` and `raised_arrivals` merged the row -measures 167,754 (+240); the ceiling stays. - -**Edits outside the listed paths (authorized by the ruling or mechanical).** -`crates/graph/src/lib.rs` (deletion), `crates/host-core/src/{lib.rs,render_session.rs}`, -`crates/control-plane/src/compile.rs`, `crates/capi/tests/resource_lifecycle.rs` (destructure, -term, re-pin note), `hosts/host-web/src/tests.rs` (the tap test), -`crates/capi/include/miso_engine_v1.h` (comment only: the "one case is not covered yet" sentence -now states the owner carry and the source rule), `docs/CONTROL_PROTOCOL_SEMANTICS.md`, -`docs/C_ABI_V1_QUALIFICATION.md`. No edit under `crates/engine/src/realtime/`. diff --git a/.github/ISSUE_SPECS/1702-model-the-engine-seqlock-under-loom.md b/.github/ISSUE_SPECS/1702-model-the-engine-seqlock-under-loom.md deleted file mode 100644 index 5a82645dd..000000000 --- a/.github/ISSUE_SPECS/1702-model-the-engine-seqlock-under-loom.md +++ /dev/null @@ -1,112 +0,0 @@ -# Model the engine seqlock under loom - -Root ruling of 2026-10-16, inside batch 15c. Follows #1316 (B15-R1). Feature sprint mode (owner, -2026-10-13): one implementer, normal tests, a batch review. Issue number: assigned at filing -(written as #1702). - -## Product outcome - -The engine's one single-writer seqlock is proved under a weak memory model, not only stressed on -x86. Its two contracts, the applied-revision watermark and the per-ring seek report, are read by -hosts on iOS and Android (AArch64), where a missing fence or a missing sequence re-check tears a -record that x86's strong ordering hides. A loom model of the real code runs in CI on every -qualifying change. - -## Context - -- `crates/engine/src/realtime/seqlock.rs` (new in #1316, B15-R1): `PublishedWords`, its - writer's `publish` (the one `REALTIME_POLICY_BEGIN`/`END` region) and its reader's bounded - `read`. Before this issue it took `AtomicU64`, `Ordering`, `fence` and `Arc` from `core`/`std` - directly, so loom could not model it; its only concurrency test was the x86 stress test - `a_read_is_one_whole_publication_or_busy`. -- `crates/engine/src/realtime/watermark.rs` (applied-revision watermark, #1314) and - `crates/source/src/lib.rs` (seek report, #1316) are its two instances. -- `crates/engine/src/realtime/spsc.rs` owns the engine's `sync` shim: `core`/`std` in every - shipped build, `loom` under `--cfg loom`. The `spsc_loom_*` models run on it. -- CI: `.github/workflows/qualification.yml`, step "Realtime race model (engine SPSC loom leg)", - `CARGO_TARGET_DIR=target/ci/loom RUSTFLAGS='--cfg loom --check-cfg=cfg(loom)'`. - -## Decisions frozen - -- **D1. One shim.** The seqlock takes `Arc`, `AtomicU64`, `Ordering` and `fence` from the existing - `spsc.rs` `sync` shim; no second shim. A non-loom build resolves to the same `core`/`std` items - as before, so shipped code is unchanged. -- **D2. Orderings unchanged.** The protocol's orderings stay as #1316 shipped them unless loom - finds a defect; a defect is reported before it is fixed. -- **D3. The real code is the model.** The loom test calls `published_words`, `publish` and `read`; - no hand-written copy of the protocol. -- **D4. A loom-only read bound.** Under `--cfg loom` one read takes 2 attempts instead of - `MAXIMUM_READ_ATTEMPTS` (64): the no-tear argument holds per attempt, and 64 spinning attempts - are beyond loom's reach. Shipped builds keep 64. -- **D5. Own CI step.** The model runs in its own step next to the SPSC leg, same target dir and - flags, filtered to `seqlock_loom`, so neither filter can drop the other's models. - -## Authorized paths - -- `crates/engine/src/realtime/seqlock.rs` -- `crates/engine/src/realtime/spsc.rs` (the `sync` shim's visibility and its `fence` item only) -- `.github/workflows/qualification.yml` (the loom step only) -- this spec - -## Objective gates - -1. `seqlock_loom_a_read_is_one_whole_record_or_busy`: one writer publishes two records (`[1, 1]`, - `[2, 2]` over the initial `[0, 0]`) while one reader reads; every `Ok` is one whole record, and - a read after the writer is joined is `[2, 2]`. `Busy` is allowed. Passes under loom's default - bounds. -2. CI runs it: `CARGO_TARGET_DIR=target/ci/loom RUSTFLAGS='--cfg loom --check-cfg=cfg(loom)' - cargo test --locked --release -p engine --lib seqlock_loom`, and the `spsc_loom` leg still - passes. -3. Commands: `cargo fmt --all`; `cargo clippy --locked -q -p engine --all-targets --all-features - -- -D warnings`; `cargo test --locked -q -p engine --features engine/realtime-audit --lib`; - `cargo run --locked --release -q -p realtime-policy` (region count unchanged); - `RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --no-deps`; - `python3 -B scripts/check-ci-path-routing.py`, `check-test-support-ci.py`, - `check-script-reachability.py`. - -## Test value - -Gate 1 turns red if the reader's `Acquire` fence, the reader's second sequence compare, or the -writer's `Release` fence is removed or weakened: each lets a reader on a weakly ordered target -(AArch64) return words of two publications as one record, which the x86 stress test cannot see. - -## Dependencies - -- #1316 (the seqlock module, B15-R1). - -## Decision record: #1702 (batch 15c) - -**Shipped.** - -- `crates/engine/src/realtime/spsc.rs`: the `sync` module is `pub(super)`; `Arc`, `AtomicU64`, - `Ordering` and the new `fence` item are `pub(in crate::realtime)`, in both the `core`/`std` and - the `loom` variants. Everything else stays `pub(super)`. -- `crates/engine/src/realtime/seqlock.rs`: imports `Arc`, `AtomicU64`, `Ordering`, `fence` from the - shim (`core::sync::atomic::AtomicUsize` stays direct: it only sizes the retained-bytes mirror). - New private `READ_ATTEMPTS` (2 under loom, `MAXIMUM_READ_ATTEMPTS` otherwise, D4) bounds `read`. - New `loom_tests::seqlock_loom_a_read_is_one_whole_record_or_busy`. No ordering changed (D2): loom - found no defect. The publish region is still one marked region; `realtime-policy` reports 151 - regions in 33 files, as before. -- `.github/workflows/qualification.yml`: new step "Realtime race model (engine seqlock loom leg)" - after the SPSC leg, same `CARGO_TARGET_DIR` and `RUSTFLAGS`, filter `seqlock_loom` (D5). - -**Model size.** A first model with two reads per run and the full 64-attempt bound did not finish -in 9 minutes; two reads with the 2-attempt bound took 302 s. The shipped model (one read, 2 -attempts) explores in 4.3 s (about 28 s wall with the incremental build on the shared loom target -dir). The SPSC leg is unchanged: 15 models, 55 s. - -**Red evidence** (scratch copy of the workspace outside the tracked tree, same loom command): - -| Mutation | Result | -| --- | --- | -| Reader's `fence(Ordering::Acquire)` removed | FAILED: `a read returned a torn record [1, 2]` | -| Reader's second sequence compare removed (return after the word loads) | FAILED: `a read returned a torn record [0, 1]` | -| Writer's `fence(Ordering::Release)` removed | FAILED: `a read returned a torn record [1, 2]` | - -**Checks.** fmt, clippy (`-D warnings`), the engine lib tests with `realtime-audit` (91 passed), -both loom legs, `realtime-policy` (ok, 151 regions in 33 files), `cargo doc` with `-D warnings`, -and the three CI scripts all pass. - -**Known limit.** `cargo test` with a filter that matches no test passes; a rename of the model -that drops the `seqlock_loom` prefix would leave the CI step green and empty. The name carries the -prefix for that reason. From 4d894f3ebbed08596541f7f4887c6c9b505062b0 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 17:27:09 +0000 Subject: [PATCH 02/35] Record root's batch 17 rulings R17-1 to R17-6 in the specs #1650 keeps the fader offset only (R17-6); the trim offset moves to #1658 and the send offset to #1654. #1647 records R17-2, R17-3 and R17-5. #1648 and #1649 note R17-1. Co-Authored-By: Claude Opus 5.5 --- ...7-render-moving-stored-fader-automation.md | 51 +++++++++++++ ...assify-and-carry-fader-automation-edits.md | 16 ++++ ...ca-offsets-with-stored-fader-automation.md | 12 +++ ...-automated-fader-or-trim-by-a-hand-move.md | 75 +++++++++++++++++++ ...ed-send-gain-mute-and-matrix-automation.md | 45 +++++++++++ ...ored-input-trim-and-polarity-automation.md | 44 +++++++++++ 6 files changed, 243 insertions(+) diff --git a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md index 51a78d4f9..ca348dce0 100644 --- a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md @@ -365,3 +365,54 @@ Departure 3: a unit test beside the reader in `crates/source/src/lib.rs`. Gate 7 `NodeSpan`, `a(n)` and jump length per block; no audible change; about 4 h); **#1647b** events on the fader (D1.1-D1.5, D2, D3, D4; gates 1-6; about 7 h); **#1647c** the held-ramp obligation of the batch 13b amendment (gate 8; about 5 h). + +## Rulings (root, batch 17, 2026-10-16) + +These rulings override the filed text and every amendment above where they differ. They answer the +ruling questions Q1 and Q2 of the batch 17 amendment and add one requirement for the reader. + +### R17-2 (Q1): option A + +At the end of `compile_with_builtins` the graph compiler gives each builtin bank its members' +floored input arrivals `a(n)` through a new defaulted method on the prepared builtin bank processor +trait (`GraphPreparedBuiltinBankProcessor`). + +- No downcast in host-core. Host-core installs nothing after the compile. +- `a(n)` is the floored arrival (`GraphCompileReport::input_arrivals`, #1285, batch 15b fold). +- `crates/graph-compiler/src/compile.rs` is an authorized path of this issue now, not "Q1 = A + only". Gate 7 runs `cargo test --locked -p graph-compiler` always. +- This work is #1647's (#1647a, the plumbing). #1646's departure 1 already moves `a(n)` here. Its + slice list must name it as #1647's too. The coordinator makes that edit in #1646, not this + ruling. + +### R17-3 (Q2): option B + +The graph sets the per-block node-time context on each builtin slot through `builtin_processor_mut` +(`crates/graph/src/runtime.rs`), with a new defaulted method on the same trait, before +`chain.run`. `process` reads it. `rack` is not edited. + +- The set must be allocation-free and O(slots) per block. +- It is covered by the realtime-policy gate and the allocation gate (gate 6). The brief for + #1647a runs gate 6's counter on the context-set path, because #1647b is not yet in when #1647a + lands. +- `crates/rack/src/lib.rs` is not an authorized path. + +### R17-4: the test-only scalar oracle + +This issue mentions the oracle (departure 5). #1646's Q2 is ruled option B. The scalar oracle gets +the same event driver as the bank processors, and its render path installs the program through the +one shared event driver. The scalar backend is not refused. So the oracle line of the authorized +paths ("the oracle per #1646 Q2") reads: the oracle gets the event driver. The coordinator records +the matching ruling in #1646. + +### R17-5: the signed timeline reader is accepted + +The signed form of `TimelineConsumer::timeline_block` and of the driver's `timeline_block` +(departure 3) is accepted. It must not overflow. + +- Every conversion between the unsigned source-read sample and the signed first sample is checked + or saturating. The same holds for `r + ΣP - a(n)` and for the extrapolation back from sample 0. +- A unit test beside the reader in `crates/source/src/lib.rs` runs at the `u64` and `i64` limits: + a first sample above `i64::MAX`, at `i64::MAX`, at `0` and at `i64::MIN`, and a block that crosses + each of them. It checks that nothing panics and nothing wraps. +- This is part of #1647a. diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index 8e5d325fa..351961682 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -372,3 +372,19 @@ where they differ. obligation, `across_withdrawn`) 8-10 h, tests 5 h. Split into two attempt-bounded slices in the same push: **#1648a** classification (D1, D3's hook and predicate, D4, gate 1, the #1639 gap gate) and **#1648b** carry (D2 and the batch 13b items, gates 2-4 and 6). + +## Rulings (root, batch 17, 2026-10-16) + +No change to the decisions, paths or gates of this issue. Two notes, so a brief does not assume the +other options. + +- **R17-1 = A closes departure 1.** The automation entries that drive a lane are an owner of the + in-place edit (`OwnerEdits`, `OwnerSlots`, `ModelView`). The fast classifier path (`classify_rows`) + therefore sees the automation table, and its assumption "the edits leave the automation table as + committed" holds with the offset write of #1650. D1's comparison still excludes the offset + (departure 3). +- **R17-6 narrows #1650 to the fader.** The 2026-10-14 amendment lists #1654 and #1658 among the + rows that use `automated_cell` for the "automation wins" rule. For #1654 that is the send matrix + rows and the send mute only. The send gain (#1654) and the trim (#1658) take a hand offset, not + `model_only`, from their own slices, and their rule is #1650's. Polarity stays "automation wins". + No step here changes. diff --git a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md index 6f62a9410..d3e5cfb57 100644 --- a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md @@ -287,3 +287,15 @@ where they differ. and the control-plane write 2 h, the cell writer, the dirty read and the jump on both bank kinds 3-4 h, tests (both hosts, the nested-order case, realtime binary) 5 h. One attempt; no split (the classifier and the render halves are not useful apart). + +## Rulings (root, batch 17, 2026-10-16) + +No change to the decisions, paths or gates of this issue. Two notes, so a brief does not assume the +other options. + +- **R17-1 = A and departure 2.** The automation entries that drive a lane are an owner of the + in-place edit. `LocalDiff` (the fast path) reads the lane's entries through the same `ModelView` + as the full path, so the automated-lane branch of `level_records` decides the same on both paths. +- **R17-6.** `S = offset + Σ reaching VCA offsets` is the fader's hand offset only. The trim and + send offsets live in #1658 and #1654, which keep their own cells. This issue's `S` does not change + and its offsets cell is the fader's. diff --git a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md index 0b9d04e07..4f3028e2f 100644 --- a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md +++ b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md @@ -334,3 +334,78 @@ where they differ. - **Recommendation: A.** It is the correct long-term shape (no path-dependent cost, no D1 change) and keeps decision 17 C3's one binding place in `controls`. B would be a shortcut that a later issue must remove. + +## Rulings (root, batch 17, 2026-10-16) + +These rulings override the filed text and every amendment above where they differ. + +**New title: "Store a hand move on an automated fader as a dB offset".** The file name does not +change. Reason: ruling R17-6 below. + +### R17-1: option A + +The automation entries that drive a lane become an owner of the in-place edit. `OwnerEdits`, +`OwnerSlots` and `ModelView` carry the rendered entries that drive the written lane. The builtin +row's read and write take the entry, with model scope. + +- A hand move on an automated fader writes the offset (OQ1). The reply and the sound agree. +- An ack of a value that does not sound is a defect. +- `crates/session/src/in_place.rs` and `crates/protocol/src/model.rs` (`prepare_in_place`) are + authorized paths now, not "if R17-1 = A". The #1650b size of 10-14 hours (R17-1 = A) stands. +- The fast classifier path (`classify_rows`) then reads the lane's entries through the same + `ModelView`. The assumption of #1648's departure 1 holds on that path. The brief for #1650b must + say this and test it. +- **Gate kept and widened.** The same `SET_CONTROL` through the in-place path (a transaction of only + that edit) and through the full path gives the same reply and the same bits. It runs on both + hosts: the C ABI (`crates/capi/src/runtime/live_tests.rs`) and the browser + (`hosts/host-web/tests/control_parity.rs`). It is part of gate 2. + +### R17-6: this issue delivers the fader offset only + +The trim offset moves to #1658 (render stored input trim and polarity automation). The send offset +moves to #1654 (render stored send gain, mute and matrix automation). No inert field stays here. + +**What leaves this issue.** + +- Product outcome: the sentence "The same rule holds for the input trim ... from the slice that + renders trim automation (#1658)" leaves. The trim rule is #1658's. +- Deliverables and decisions: + - D2: `takes_offset` is true for `fader_db` only. `trim_db` leaves (to #1658). Route `gain_db` + leaves (to #1654). + - D3: the words "(or, from #1658, trim)" and the last bullet (trim before #1658) leave. After + this ruling `SET_CONTROL trim_db` is untouched by this issue. A trim entry is not rendered + (A10), so it stays inert as every unrendered row is, and its offset must be `+0.0`. + - Batch 10 amendment: the shared-row rule (one offset, `left_db` equals `right_db`), the route + `gain_db` accessor, the route lowering of D5 and the domain note for the route gain leave + (to #1654). Its remark on a VCA `offset_db` (#1655) is not changed by this ruling. + - Batch 17 amendment: the "Trim" bullet (a trim entry carries a checked, stored, inert offset) + is void. The binding switch of R17-1 covers row 5 only, not rows 2 and 5. +- Authorized paths: no path leaves, because the fader needs every one of them. This issue adds no + trim or route file (`crates/controls/src/route.rs` is #1654's). The work in each path is smaller. +- Gates: + - Gate 1 loses the batch 10 additions: a send `gain_db` entry whose two fields differ, and a + non-zero offset on a send matrix row, move to #1654. + - Gate 1 keeps: a non-zero offset on any row that does not take one is refused + `control.automation.offset`. A trim entry and a send entry are such rows until #1658 and #1654 + add them to `takes_offset`. This keeps the field from being an inert, accepted value. + - Gates 2 to 6 are about the fader only, as they read already. No trim gate and no send gate stay. +- The field is one struct for all entries (the V1 grammar has no optional key; D1). It holds `+0.0` + on every entry that is not a fader entry, until the slice that renders that row adds the row to + `takes_offset`. The field is heard for the fader in this issue, so it is not inert. +- Non-goals now also name: an offset on `trim_db` (#1658) and on a send `gain_db` (#1654). +- Dependencies: the line "#1658 (amended) gives the trim its render half and flips D3 for trim" + now reads: #1658 and #1654 depend on this issue. This issue ships the shared mechanism, tested + with the fader: the field and grammar (#1650a), the offset write, the owner rule of R17-1 and the + list flag (#1650b). + +**How the split reads now.** + +- **#1650a** (the field, the grammar, the wire, the SDK, the fixtures, the docs): D1; D2 with + `takes_offset` = `fader_db` only, the domain check and the lane rule; gate 1 as kept above. It + is inert for every row until #1650b, so it can land before #1646. The batch 17 path list for + #1650a does not change. +- **#1650b** (the fader write, the in-place owner, the list flag, the classifier): D3 to D5 for + `fader_db`, R17-1 = A, the `automated` list field, and gates 2 to 6 with the widened gate 2. The + batch 17 path list for #1650b does not change. It lands in the R1 push with #1646 to #1649, with + the "no middle state" hazard. +- Neither part has a trim or send step. #1658 and #1654 add their rows after this issue lands. diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 80a369dde..460aaf48b 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -192,3 +192,48 @@ hand offset; a hand send mute ORs (D5 confirmed); on the send matrix automation - **Non-goals.** "VCA automation (README F20)" is #1655. - **Dependencies** gain #1650's amended field and rule (batch R1) and #1632 (ordering only: its row rule refuses the `follows_mute` entry this slice no longer renders). + +## Rulings (root, batch 17, 2026-10-16) + +These rulings override the filed text and every amendment above where they differ. + +### R17-6: this issue owns the hand offset for the send gain + +Under OQ1 = C, a hand move of an automated send `gain_db` adds a stored dB offset. The send offset +(its field use, its write and its gates) moved here from #1650. #1650 now delivers the fader offset +only. This issue depends on #1650, which ships the shared mechanism for the fader: the offset +field and grammar (#1650a), and the offset write, the in-place owner rule and the list flag +(#1650b). The batch 10 amendment above still gives the render half (the seven-word cell and +`g = clamp(f64(curve) + f64(offset), -144, 24)`). Where it says "#1650's offset field, amended for +the send gain", read: the field of #1650 D1, with the rule below. + +The rule is the one of #1650, for route row 1 `gain_db` on a route into a submix: + +- **The offset field.** The field is the one of #1650 D1. The row is shared (`lane: both`), so its + entry holds one offset: `left_db` and `right_db` are equal. This issue adds route `gain_db` on a + route into a submix to `takes_offset`, in the same slice that adds route rows 1 to 6 to + `automation_row_renders`. `control.automation.offset` also refuses an entry on this shared row + whose two fields differ. The domain is `[-144, 24]` dB (`ROUTE_GAIN_DB_MINIMUM`, + `ROUTE_GAIN_DB_MAXIMUM`). Before this issue a non-zero offset on a send entry is refused. The + matrix rows, `mute` and `follows_mute` take no offset: a non-zero offset on a send matrix row is + refused. +- **The write.** On a send whose `gain_db` automation renders, `SET_CONTROL` on route row 1 writes + the entry's offset (both fields, one value), not the static gain. The path is `live`. The control + list reports the offset as the row's `value` and `automated = true`. The seven-word cell's `gain` + word and its ramp are written as in the batch 10 amendment. +- **The in-place owner rule (R17-1).** The automation entries that drive the route row are an owner + of the in-place edit for route row 1: `OwnerEdits`, `OwnerSlots`, `ModelView`, and the row's read + and write with model scope. A hand move reaches the offset on every path. The reply and the sound + agree. An ack of a value that does not sound is a defect. The matrix rows keep D4: automation + wins, the edit is stored and commits `model_only`. +- **Equivalence gate.** The same `SET_CONTROL` on an automated send gain through the in-place path + and through the full path gives the same reply and the same bits. It runs on both hosts: the C + ABI (`crates/capi/src/runtime/live_tests.rs`) and the browser + (`hosts/host-web/tests/control_parity.rs`). +- **Gates moved here from #1650.** An entry on a send `gain_db` whose two offset fields differ is + refused `control.automation.offset`. A non-zero offset on a send matrix row is refused. Gate 3 of + the batch 10 amendment (the `+3` hand move) is the render check of this rule. +- **Paths added to this issue's list:** `crates/controls/src/{builtin_rows.rs,owner_edits.rs}` (the + route row 1 binding and its owner), `crates/controls/tests/**`, and the fields #1650 introduces. + `docs/SESSION_SCHEMA_V1.md` names the send gain as taking the offset. The batch R2 order stands: + this issue follows #1650 and #1651 to #1653. diff --git a/.github/ISSUE_SPECS/1658-render-stored-input-trim-and-polarity-automation.md b/.github/ISSUE_SPECS/1658-render-stored-input-trim-and-polarity-automation.md index 3c4e121ac..497733ef5 100644 --- a/.github/ISSUE_SPECS/1658-render-stored-input-trim-and-polarity-automation.md +++ b/.github/ISSUE_SPECS/1658-render-stored-input-trim-and-polarity-automation.md @@ -216,3 +216,47 @@ Checked against `12d4a4f9d`. length of D2. - **Browser commands** `COMMAND_TRIM_DB` and `COMMAND_POLARITY_INVERT` are deleted (#1565). #1261 and #1346 have landed. Realtime policy as in #1637's amendment. + +## Rulings (root, batch 17, 2026-10-16) + +These rulings override the filed text and every amendment above where they differ. + +### R17-6: this issue owns the hand offset for the input trim + +Under OQ1 = C, a hand move of an automated trim dB adds a stored dB offset. The trim offset +(its field use, its write and its gates) moved here from #1650. #1650 now delivers the fader offset +only. This issue depends on #1650, which ships the shared mechanism for the fader: the offset +field and grammar (#1650a), and the offset write, the in-place owner rule and the list flag +(#1650b). + +The rule is the one of #1650, for trim (row 2, `trim_db`, builtins control 2): + +- **The offset field.** The field is the one of #1650 D1: `offset { left_db, right_db }` on the + entry that drives the lane. This issue adds `trim_db` to `takes_offset` in + `controls::automation_diagnostics`, in the same slice that adds row 2 to + `automation_row_renders`. The domain is `[-144, 24]` dB. Before this issue a non-zero offset on a + trim entry is refused `control.automation.offset`. A polarity entry (row 1) takes no offset. +- **The write.** On a lane whose trim automation renders, `SET_CONTROL trim_db` writes that lane's + offset in the entry that drives the lane, not the static field. The path is `live`. The control + list reports the offset as the row's `value` and `automated = true`. A `both` write sets both + lanes' offsets. +- **The in-place owner rule (R17-1).** The automation entries that drive the lane are an owner of + the in-place edit for row 2: `OwnerEdits`, `OwnerSlots`, `ModelView`, and the row's read and write + with model scope. A hand move reaches the offset on every path. The reply and the sound agree. An + ack of a value that does not sound is a defect. +- **Classifier.** For an automated trim lane the lowering writes the trim offsets cell (the 2026-10-14 + amendment above) with `LiveRamps::resolve(InputTrim, the edit's ramp)`. It writes no `TrimDb` + record. An offset-only `UpsertAutomation` is classified the same way. +- **Equivalence gate.** The same `SET_CONTROL trim_db` through the in-place path and through the full + path gives the same reply and the same bits. It runs on both hosts: the C ABI + (`crates/capi/src/runtime/live_tests.rs`) and the browser (`hosts/host-web/tests/control_parity.rs`). +- **Gates moved here from #1650.** A hand move of `+3` on a trim ride is heard as the ride plus + `+3` after the ramp, bit for bit equal to a fresh plan whose entry carries that offset, and the + other lane is unchanged. Offset `0` and `-0.0` give the bits of no offset. Re-authoring the + segments keeps the offset. A non-zero `right_db` on a `left` trim entry is refused. The 2026-10-14 + amendment's bit rule for a flat trim stands. +- **Polarity stays "automation wins"** (D5 for row 1). No offset reaches it. +- **Paths added to this issue's list:** `crates/controls/src/{automation.rs,builtin_rows.rs,owner_edits.rs}` + and `crates/controls/tests/**` (the `takes_offset` entry, the row 2 binding and its owner), + `hosts/host-web/tests/control_parity.rs`, `docs/SESSION_SCHEMA_V1.md` (the automation section + names trim as taking the offset). The batch R2 order stands: this issue follows #1650. From ba240b6e170856de1077d58a8e2ae40f73ddf50e Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 17:33:30 +0000 Subject: [PATCH 03/35] Sum the VCA offsets first, then add the member (#1646 D6) session gains vca_offsets_sum and vca_compose_db; vca_effective_db and VcaSum are written from them, so every caller takes the new order. Gate 6 and the host-core oracle state the new order. audit capi pcm_digest unchanged (bffc21e857b5e134). Records root's rulings R17-2 and R17-4 in #1646. Co-Authored-By: Claude Opus 5.5 --- .../1646-prepare-stored-fader-automation.md | 37 +++++++++ crates/controls/src/effective.rs | 7 +- crates/controls/tests/composition.rs | 55 ++++++++---- crates/host-core/tests/vca.rs | 15 ++-- crates/session/src/lib.rs | 5 +- crates/session/src/vca.rs | 83 ++++++++++++------- docs/SESSION_SCHEMA_V1.md | 4 +- 7 files changed, 148 insertions(+), 58 deletions(-) diff --git a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md index d6856a452..48ae500ea 100644 --- a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md @@ -444,3 +444,40 @@ Gate 6: `crates/controls/tests/composition.rs`. Gate 7 adds `cargo test --locked **#1646a** D6 only (session, controls effective and its test, host-core oracle, schema sentence; about 2 h, closable alone: class B, ruled) and **#1646b** the preparation (about 7 h). Both stay in batch R1. + + +## Decision record: #1646a (batch 17) + +D6 only. Shipped: + +- `crates/session/src/vca.rs`: `vca_offsets_sum(offsets) -> Option` (first offset starts the + sum, no `+0.0` seed) and `vca_compose_db(member, sum) -> f32` + (`(f64(member) + sum).clamp(-144, 24) as f32`); `vca_effective_db` is written from them (member + bit for bit with no offset). `pub use` in `crates/session/src/lib.rs`. +- Departure in shape, not in arithmetic: `VcaSum` no longer takes the member. `VcaSum::new()` is an + empty offsets accumulator with `add`, `sum() -> Option` and `compose(member) -> f32` + (replacing `finish`), because the member now joins after the offsets. Its only caller is + `controls::effective_strip` (`crates/controls/src/effective.rs`), which now builds + `[VcaSum::new(); 2]` and calls `compose(own_db[lane])`. No per-lane `S` field (that is #1646b). +- `docs/SESSION_SCHEMA_V1.md:83-84` states the new order. +- Gate 6 is `vca_effective_db_sums_in_f64_in_order_and_clamps_once` + (`crates/controls/tests/composition.rs`), rewritten in place: reference sums offsets first, also + checks `vca_offsets_sum` + `vca_compose_db` against it, crafted cases + `(24, [-24, 1e-30]) == 0.0`, `(1e-30, [24, -24]) == 1e-30`, `(0, [24, -24, 1e-30]) == 1e-30` + (plus `(0, [1e-30, 24, -24]) == 0.0` for the offset order and a `-0.0` lone-offset case for the + no-seed rule). +- Host oracle `reference_db` in `crates/host-core/tests/vca.rs` rewritten to the new order. The + other callers (`crates/capi/src/runtime/live_tests.rs:7040,7082`) call `vca_effective_db` + itself, so they follow without edits. The two browser oracles of D6 no longer exist (#1565). +- Measured: `./target/release/audit capi` pcm_digest `bffc21e857b5e134` (unchanged from base), + 0 violations. No pinned render or digest moved; `-p session`, `-p controls`, + `-p host-core --features host-core/test-support`, `-p capi`, `-p host-web --features + host-web/test-support` all pass; clippy (`--all-features`) and no-feature `cargo check` clean. + +## Rulings (root, batch 17, 2026-10-16) + +- **Q1 (R17-2): A.** `a(n)` belongs to #1647. At the end of `compile_with_builtins` the graph + compiler gives each builtin bank its members' floored input arrivals through a new defaulted + processor method. No downcast in host-core. This slice needs no `a(n)` (departure 1). +- **Q2 (R17-4): B.** The program is installed on the test-only scalar oracle too, through the one + shared event driver. The scalar backend is not refused. diff --git a/crates/controls/src/effective.rs b/crates/controls/src/effective.rs index f8c077dfb..844f50ef7 100644 --- a/crates/controls/src/effective.rs +++ b/crates/controls/src/effective.rs @@ -317,8 +317,9 @@ pub fn effective_strip( let reach = inputs.reach(strip); let solo_term = solo_term(inputs, strip, any_solo); let mut out = EffectiveStrip::default(); - // One read of each reaching VCA feeds both lanes; the sum is `vca_effective_db`'s. - let mut sums = [VcaSum::new(own_db[0]), VcaSum::new(own_db[1])]; + // One read of each reaching VCA feeds both lanes; the sum is `vca_effective_db`'s: the + // offsets first, then the member. + let mut sums = [VcaSum::new(); 2]; for &vca in reach { let (offset_db, muted) = inputs.vca(vca); for lane in 0..2 { @@ -327,7 +328,7 @@ pub fn effective_strip( } } for lane in 0..2 { - out.fader_db[lane] = sums[lane].finish(); + out.fader_db[lane] = sums[lane].compose(own_db[lane]); out.mute[lane] = own_mute[lane] || out.vca_mute[lane] || solo_term; } out diff --git a/crates/controls/tests/composition.rs b/crates/controls/tests/composition.rs index 222e74395..50ce3ebe0 100644 --- a/crates/controls/tests/composition.rs +++ b/crates/controls/tests/composition.rs @@ -4,7 +4,8 @@ use controls::{EffectiveStrip, MonitorOverlay, effective}; use session::{ - DualMonoFader, SessionModel, StableId, Submix, Vca, parse_session_json, vca_effective_db, + DualMonoFader, SessionModel, StableId, Submix, Vca, parse_session_json, vca_compose_db, + vca_effective_db, vca_offsets_sum, }; const EXAMPLE: &str = include_str!("../../../fixtures/session/v1/canonical.json"); @@ -32,24 +33,28 @@ impl Rng { } } -/// The composition's own statement, evaluated independently: an `f64` sum in the given order, then -/// the domain clamp, then one rounding. +/// The composition's own statement (#1646 D6), evaluated independently: the offsets summed first in +/// `f64`, in the given order, from the first offset; then the member added in `f64`; then the domain +/// clamp; then one rounding. fn reference_db(member: f32, offsets: &[f32]) -> f32 { - if offsets.is_empty() { + let Some((first, rest)) = offsets.split_first() else { return member; - } - let total = offsets + }; + let sum = rest .iter() - .fold(f64::from(member), |sum, offset| sum + f64::from(*offset)); - total.clamp(-144.0, 24.0) as f32 + .fold(f64::from(*first), |sum, offset| sum + f64::from(*offset)); + (f64::from(member) + sum).clamp(-144.0, 24.0) as f32 } -/// Gate 1, the gain: random sums equal the `f64` reference bit for bit; no offset is the member's -/// own value bit for bit (`-0.0` and an out-of-domain value included); the sum clamps at both -/// edges; and the crafted order case is `(24 - 24) + 1e-30`, never `(24 + 1e-30) - 24 = 0`. +/// Gate 6 (#1646 D6, gate 1 of #1242 rewritten in place), the gain: random sums equal the `f64` +/// reference bit for bit, the offsets summed first and the member added to that sum; no offset is +/// the member's own value bit for bit (`-0.0` and an out-of-domain value included); the sum clamps +/// at both edges; and the crafted cases pin the order: `24 + (-24 + 1e-30) = 0` (the offsets' sum +/// rounds `-24 + 1e-30` to `-24`), `1e-30 + (24 - 24) = 1e-30`, and the offsets in the order +/// given (`0 + ((24 - 24) + 1e-30) = 1e-30`). /// -/// Red if the sum accumulates in `f32` (the random draws round differently), runs in another order -/// (the crafted case), clamps a member with no VCA, or does not clamp. +/// Red if the sum accumulates in `f32` (the random draws round differently), adds the member first +/// (the crafted cases), runs in another offset order, clamps a member with no VCA, or does not clamp. #[test] fn vca_effective_db_sums_in_f64_in_order_and_clamps_once() { let mut rng = Rng(0x2545_f491_4f6c_dd1d); @@ -64,6 +69,11 @@ fn vca_effective_db_sums_in_f64_in_order_and_clamps_once() { expected.to_bits(), "{member} + {offsets:?}: {actual} != {expected}" ); + let sum = vca_offsets_sum(offsets.iter().copied()); + assert_eq!(sum.is_none(), offsets.is_empty()); + if let Some(sum) = sum { + assert_eq!(vca_compose_db(member, sum).to_bits(), expected.to_bits()); + } } for member in [-0.0_f32, 0.0, 24.5, -144.5, -6.25, f32::MIN_POSITIVE] { assert_eq!( @@ -72,15 +82,28 @@ fn vca_effective_db_sums_in_f64_in_order_and_clamps_once() { "{member} with no offset" ); } + assert_eq!(vca_offsets_sum([]), None); + // The sum starts at the first offset, with no `+0.0` seed. + assert_eq!( + vca_offsets_sum([-0.0]).map(f64::to_bits), + Some((-0.0_f64).to_bits()) + ); + assert_eq!(vca_offsets_sum([1.5, 2.25, -0.5]), Some(3.25)); assert_eq!(vca_effective_db(20.0, [3.0, 2.0]), 24.0); assert_eq!(vca_effective_db(-140.0, [-6.0]), -144.0); assert_eq!(vca_effective_db(-144.0, [-144.0, -144.0]), -144.0); assert_eq!(vca_effective_db(24.0, [24.0]), 24.0); - // An in-domain sum is not clamped: 24 + 6 - 6. + // An in-domain sum is not clamped: 24 + (6 - 6). assert_eq!(vca_effective_db(24.0, [6.0, -6.0]), 24.0); assert_eq!(vca_effective_db(-6.0, [6.0]).to_bits(), 0.0_f32.to_bits()); - assert_eq!(vca_effective_db(24.0, [-24.0, 1e-30]), 1e-30); - assert_eq!(vca_effective_db(24.0, [1e-30, -24.0]), 0.0); + // Member first (the old order) gave 1e-30 here: (24 - 24) + 1e-30. The offsets first round + // -24 + 1e-30 to -24, so the member cancels it. + assert_eq!(vca_effective_db(24.0, [-24.0, 1e-30]), 0.0); + // Member first gave 0.0 here: (1e-30 + 24) - 24. + assert_eq!(vca_effective_db(1e-30, [24.0, -24.0]), 1e-30); + // The offsets in the order given: (24 - 24) + 1e-30 survives; 1e-30 + 24 - 24 does not. + assert_eq!(vca_effective_db(0.0, [24.0, -24.0, 1e-30]), 1e-30); + assert_eq!(vca_effective_db(0.0, [1e-30, 24.0, -24.0]), 0.0); } /// The fixture's `vocal`, two copies `t0` and `t1`, and submix `bus-a`: strips 0 to 3. diff --git a/crates/host-core/tests/vca.rs b/crates/host-core/tests/vca.rs index 51c1231d9..50d8f6e5d 100644 --- a/crates/host-core/tests/vca.rs +++ b/crates/host-core/tests/vca.rs @@ -265,17 +265,18 @@ fn reference_reach(vcas: &[Vca], strip: &str) -> Vec { reach } -/// Decision 13 (a) as the test states it: `own + offsets` in `f64`, the offsets in ascending VCA -/// ID order, clamped to `[-144, 24]` in `f64`, rounded once; the own value exactly when unreached. +/// Decision 13 (a) in the order of #1646 D6, as the test states it: the offsets summed first in +/// `f64` (ascending VCA ID order, from the first offset), then `own` added to that sum, clamped to +/// `[-144, 24]` in `f64`, rounded once; the own value exactly when unreached. fn reference_db(own: f32, offsets: &[f32]) -> f32 { - if offsets.is_empty() { + let Some((first, rest)) = offsets.split_first() else { return own; - } - let mut sum = f64::from(own); - for offset in offsets { + }; + let mut sum = f64::from(*first); + for offset in rest { sum += f64::from(*offset); } - sum.clamp(-144.0, 24.0) as f32 + (f64::from(own) + sum).clamp(-144.0, 24.0) as f32 } /// A random forest of up to eight VCAs over `strips`, at most four levels deep: a VCA's members diff --git a/crates/session/src/lib.rs b/crates/session/src/lib.rs index ff33ad57c..f9e260085 100644 --- a/crates/session/src/lib.rs +++ b/crates/session/src/lib.rs @@ -35,7 +35,10 @@ pub use in_place::{ }; pub use model::*; pub use parse::parse_session_json; -pub use vca::{FADER_MAXIMUM_DB, FADER_MINIMUM_DB, VcaNesting, VcaSum, vca_effective_db}; +pub use vca::{ + FADER_MAXIMUM_DB, FADER_MINIMUM_DB, VcaNesting, VcaSum, vca_compose_db, vca_effective_db, + vca_offsets_sum, +}; pub use visit::{FieldKey, ModelVisitor, Token, VARIANT_ARMS, VisitModel, WalkOrder, keys}; /// The only schema version accepted by [`parse_session_json`]. diff --git a/crates/session/src/vca.rs b/crates/session/src/vca.rs index 74c4d2c29..a84806a5e 100644 --- a/crates/session/src/vca.rs +++ b/crates/session/src/vca.rs @@ -5,7 +5,8 @@ //! lane gain is `clamp(own_db + sum(offsets), -144, 24)` over the **set** of VCAs that reach it, //! and its effective lane mute is its own mute ORed with every reaching VCA's mute. The composition //! itself (the fader, the mute, the solo term and the route gate) is `controls::effective` (decision -//! 17, C7); this module keeps the arithmetic ([`vca_effective_db`]) and the reach +//! 17, C7); this module keeps the arithmetic ([`vca_effective_db`]: the offsets first, then the +//! member, #1646 D6) and the reach //! ([`SessionModel::vca_reach`]) that it calls, so there is one sum and one reach. use std::cell::OnceCell; @@ -19,52 +20,76 @@ pub const FADER_MINIMUM_DB: f64 = -144.0; /// The upper edge of the fader domain, in dB. pub const FADER_MAXIMUM_DB: f64 = 24.0; -/// Decision 13 (a): `clamp(member_db + sum(offsets), -144, 24)`, summed in `f64` in the order -/// given (the member's own value first, then each offset), clamped in `f64`, rounded once to -/// `f32`. With no offset it returns `member_db` bit for bit, unclamped. +/// The `f64` sum of the reaching VCAs' offsets, in the order given, starting from the first +/// offset (no `+0.0` seed, so a lone `-0.0` stays `-0.0`); `None` for no offset (#1646 D6). The +/// one sum a stored lane's cell holds and the static path adds the member to. #[must_use] -pub fn vca_effective_db(member_db: f32, offsets_db: impl IntoIterator) -> f32 { - let mut sum = VcaSum::new(member_db); +pub fn vca_offsets_sum(offsets_db: impl IntoIterator) -> Option { + let mut sum = VcaSum::new(); for offset in offsets_db { sum.add(offset); } - sum.finish() + sum.sum() +} + +/// Adds the member to the offsets' sum and clamps once (#1646 D6): +/// `clamp(f64(member_db) + offsets_sum, -144, 24)` in `f64`, rounded once to `f32`. +#[must_use] +pub fn vca_compose_db(member_db: f32, offsets_sum: f64) -> f32 { + // `f64::clamp` keeps a NaN, which a validated model never carries. + (f64::from(member_db) + offsets_sum).clamp(FADER_MINIMUM_DB, FADER_MAXIMUM_DB) as f32 +} + +/// Decision 13 (a), in the order of #1646 D6: `clamp(member_db + S, -144, 24)` where `S` is +/// [`vca_offsets_sum`] of the offsets (summed first, in the order given), then the member added in +/// `f64`, clamped in `f64`, rounded once to `f32`. With no offset it returns `member_db` bit for +/// bit, unclamped. +#[must_use] +pub fn vca_effective_db(member_db: f32, offsets_db: impl IntoIterator) -> f32 { + match vca_offsets_sum(offsets_db) { + None => member_db, + Some(sum) => vca_compose_db(member_db, sum), + } } -/// [`vca_effective_db`] as an accumulator, for a caller that reads each reaching VCA once and -/// feeds both lanes (batch 7 review M1): `add` the offsets in order, then `finish`. The one sum. -#[derive(Clone, Copy, Debug)] +/// [`vca_offsets_sum`] as an accumulator, for a caller that reads each reaching VCA once and +/// feeds both lanes (batch 7 review M1): `add` the offsets in order, then read [`Self::sum`] or +/// [`Self::compose`]. The one sum. +#[derive(Clone, Copy, Debug, Default)] pub struct VcaSum { - member_db: f32, - sum: f64, - any: bool, + sum: Option, } impl VcaSum { - /// A sum that starts at the member's own value. + /// An empty sum: no offset has come. #[must_use] - pub fn new(member_db: f32) -> Self { - Self { - member_db, - sum: f64::from(member_db), - any: false, - } + pub fn new() -> Self { + Self { sum: None } } - /// Adds the next reaching VCA's offset. + /// Adds the next reaching VCA's offset; the first one starts the sum. pub fn add(&mut self, offset_db: f32) { - self.sum += f64::from(offset_db); - self.any = true; + let offset = f64::from(offset_db); + self.sum = Some(match self.sum { + None => offset, + Some(sum) => sum + offset, + }); + } + + /// The offsets' sum; `None` when no offset came. + #[must_use] + pub fn sum(self) -> Option { + self.sum } - /// The clamped, once-rounded result; the member's own value, bit for bit, when no offset came. + /// The clamped, once-rounded result for a member; the member's own value, bit for bit, when no + /// offset came. #[must_use] - pub fn finish(self) -> f32 { - if !self.any { - return self.member_db; + pub fn compose(self, member_db: f32) -> f32 { + match self.sum { + None => member_db, + Some(sum) => vca_compose_db(member_db, sum), } - // `f64::clamp` keeps a NaN, which a validated model never carries. - self.sum.clamp(FADER_MINIMUM_DB, FADER_MAXIMUM_DB) as f32 } } diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index 95d43eaae..b557eb507 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -80,8 +80,8 @@ while a diamond (one VCA reachable along two paths) is legal. Canonically `vcas` own fader, never an effective value. VCAs apply at preparation on every host (#1242): a strip's reach is the set of VCAs it is reachable from through membership, directly or through nested VCAs, each once even along several paths; its effective lane gain is -`clamp(own_db + sum(offsets), -144, 24)`, summed in `f64` (its own value first, then the reaching -VCAs' offsets in ascending VCA ID), clamped in `f64` and rounded once to `f32`, and exactly its own +`clamp(own_db + sum(offsets), -144, 24)`, summed in `f64` (the reaching VCAs' offsets first, in +ascending VCA ID, then its own value added to that sum), clamped in `f64` and rounded once to `f32`, and exactly its own value with an empty reach; its effective lane mute is its own mute or any reaching VCA's mute. The prepared fader section bakes the effective values, so post-fader and post-pan sends follow a VCA, and a `follows_mute` route reads its source's effective mute. A member's own fader is still checked From 5c8781b7e60e3cf4cf6afebf4b4a746f8ef0dd39 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 17:57:04 +0000 Subject: [PATCH 04/35] Add the automation offset field, grammar and row rule (#1650a) Every automation entry carries a required offset record { left_db, right_db } (session JSON key 4, wire field 4). Only a driven strip fader_db lane takes a non-zero offset (R17-6); any other lane must hold +0.0, else control.automation.offset. SDK, fixtures, skill and docs follow. Nothing renders the field yet: #1650b, in the same push, is its production reader. COMPLETE_SCHEMA_HASH 0x0c7196f86abe6d57 -> 0x5eb9ad2ed137f3be (Automation field 4). audit capi pcm_digest unchanged (bffc21e857b5e134). Co-Authored-By: Claude Opus 5.5 --- .claude/skills/author-session/SKILL.md | 8 + .../skills/author-session/worked-session.json | 6 +- ...-automated-fader-or-trim-by-a-hand-move.md | 96 +++++++++++ crates/capi/src/runtime/live_tests.rs | 5 + crates/conformance/src/protocol_corpus.rs | 5 +- crates/controls/src/automation.rs | 53 +++++- crates/controls/src/builtin_rows.rs | 2 +- crates/controls/src/error.rs | 6 + crates/controls/tests/automation.rs | 155 +++++++++++++++++- .../host-core/tests/launch_registry_once.rs | 1 + crates/host-core/tests/live_delta.rs | 2 + crates/host-core/tests/stored_automation.rs | 1 + crates/protocol/src/schema.rs | 25 ++- crates/protocol/src/session_wire.rs | 46 +++++- crates/protocol/src/session_wire/tests.rs | 46 ++++++ crates/session/src/canonical.rs | 35 ++-- crates/session/src/model.rs | 16 ++ crates/session/src/parse.rs | 24 ++- crates/session/src/visit.rs | 7 +- crates/session/tests/invalid_matrix.rs | 72 ++++++++ .../session/tests/json_contract_artifacts.rs | 1 + docs/CONTROL_PROTOCOL_CONFORMANCE.md | 2 +- docs/SESSION_SCHEMA_V1.md | 30 +++- docs/session-v1.schema.json | 8 +- .../v1/canonical-writer-corpus.json | 4 +- fixtures/session/v1/builtins-automation.json | 36 +++- fixtures/session/v1/canonical.json | 6 +- fuzz/corpus/complete-schema-manifest.md | 5 +- hosts/host-web/src/tests.rs | 2 +- hosts/host-web/tests/control_parity.rs | 1 + scripts/check-protocol-wasm-parity.sh | 4 +- sdk/assets/miso-engine-v1-session-keys.json | 17 +- sdk/src/cli/session-request.ts | 13 +- sdk/src/core/session.ts | 14 ++ sdk/src/core/types.ts | 14 ++ sdk/src/generated/session-keys.ts | 27 +++ sdk/test/builder-evals.mjs | 101 ++++++++++++ tools/session-validator/tests/validate.rs | 3 +- 38 files changed, 842 insertions(+), 57 deletions(-) diff --git a/.claude/skills/author-session/SKILL.md b/.claude/skills/author-session/SKILL.md index 2b3c7194d..39d1c4581 100644 --- a/.claude/skills/author-session/SKILL.md +++ b/.claude/skills/author-session/SKILL.md @@ -124,6 +124,12 @@ setting row, and not a send's `follows_mute` (route row 7) or a strip's `matrix_ parameter does not have to be declared on the strip. Stored automation is inert today: it authors and round-trips, and renders nothing. +Every automation entry ends with a required `offset` record after `segments`: +`"offset": {"left_db": 0.0, "right_db": 0.0}`. It is the dB a hand move adds to the curve of each +lane (#1650). Write `0.0` on both lanes unless you store a hand move on a `fader_db` entry (builtins +5), and then only on a lane the entry drives, inside `[-144, 24]`. Every other lane holds exactly +`0.0` (not `-0.0`). + ## VCA groups `vcas` is required, `[]` when empty. A VCA is `{ "id", "fader", "members" }`: a control-only fader @@ -163,6 +169,8 @@ is `schema.unknown_field`; out of range is `numeric.out_of_schema_range` at | A segment whose unit is not the row's, or a value the row refuses (a fader above 24 dB, a filter above the rate's maximum), at `$.automation[id=].segments[]` | `control.domain` | prepare-effects | | A `linear` or `exponential` segment on a toggle (polarity, mute) or enumeration row; `exponential` on a dB row or a row whose minimum is not above 0; a `linear` or `exponential` filter segment from or to `0` ("off" is reached only by a `step`) | `control.automation.shape` | prepare-effects | | An HPF that can reach or pass an enabled LPF on a lane, at any time, with stored automation on either filter, at the LPF entry's (else the HPF entry's) segment | `control.automation.order` | prepare-effects | +| A non-zero (or `-0.0`) `offset` lane on a lane the entry does not drive, or on any row but `fader_db`, at `$.automation[id=].offset.left_db` or `.right_db` | `control.automation.offset` | prepare-effects | +| A `fader_db` entry's offset outside `[-144, 24]` dB on a lane it drives, at the same path | `control.domain` | prepare-effects | ## Buses: submix strips and their taps diff --git a/.claude/skills/author-session/worked-session.json b/.claude/skills/author-session/worked-session.json index 93fa568f2..5e41d7d3a 100644 --- a/.claude/skills/author-session/worked-session.json +++ b/.claude/skills/author-session/worked-session.json @@ -420,7 +420,11 @@ "end_value": 4.0, "unit": "db" } - ] + ], + "offset": { + "left_db": 0.0, + "right_db": 0.0 + } } ] } diff --git a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md index 4f3028e2f..b69d6c37e 100644 --- a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md +++ b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md @@ -409,3 +409,99 @@ moves to #1654 (render stored send gain, mute and matrix automation). No inert f batch 17 path list for #1650b does not change. It lands in the R1 push with #1646 to #1649, with the "no middle state" hazard. - Neither part has a trim or send step. #1658 and #1654 add their rows after this issue lands. + +## Decision record: #1650a (batch 17) + +Implemented on `codex/controls-batch-17w` over `ba240b6e1` (uncommitted for the coordinator). + +**What shipped.** + +- **D1, the field.** `session::Automation` gains `offset: AutomationOffset { left_db, right_db }` + (`f32`, `Default` is `+0.0` on both lanes), required in every entry. JSON key `offset` 4, after + `segments`, a record with `left_db` 1 and `right_db` 2 (`keys::automation`, + `keys::automation_offset`; the walk emits it, so the canonical writer, the estimator and the SDK + key tables follow). `parse_automation` requires it (`schema.missing_field`, + `schema.unknown_field`, `schema.wrong_type` at its paths). Wire: `Automation` field 4, a required + nested `AutomationOffset` with two required `F32` fields 1 and 2, encoded and decoded in + `session_wire.rs` (a non-finite lane is refused, as every session `f32`); the equal-key test + pairs the new module. `SetAutomationSegments` and `SetAutomationTarget` keep the offset (they + write one field of the entry); `RemoveAutomation` removes it with the entry. No code change was + needed for that. +- **D2, the row rule** (`controls::automation_diagnostics`, `crates/controls/src/automation.rs`). + `takes_offset(target)` is true only for a strip's builtin `fader_db` (R17-6). Per lane, after the + entry's segments, at `$.automation[id=].offset.left_db` / `.right_db`: a lane that takes no + offset (a lane the entry does not drive, or any lane of a row that takes none) must hold exactly + `+0.0` bits, else `control.automation.offset` (new `ControlError::AutomationOffset`); a lane a + `fader_db` entry drives is judged by the row's own `check`, so `[-144, 24]` dB, else + `control.domain`. An offset fault marks the entry faulted for the filter order rule, as a segment + fault does. `takes_offset` is `pub(crate)`; #1650b exports it if a crate outside `controls` needs it. +- **SDK.** `AutomationSpec.offset?: { leftDb?, rightDb? }` (default `0` per lane); the builder + writes `offset` after `segments`; the `enginectl` request decoder accepts an optional `offset` + with those two keys. Key tables regenerated (`sdk/assets/miso-engine-v1-session-keys.json`, + `sdk/src/generated/session-keys.ts`). +- **Documents.** `fixtures/session/v1/canonical.json` (1 entry), `builtins-automation.json` (6) and + `.claude/skills/author-session/worked-session.json` (1) gain `"offset": {"left_db": 0.0, + "right_db": 0.0}`; their canonical bytes are checked by the existing round-trip tests. The + canonical writer corpus (`fixtures/session-canonical/v1/canonical-writer-corpus.json`) is + regenerated by the Rust writer; its full-surface model stores a `+3.0` left offset on the left + `fader-ride`, so the SDK writer is held to a non-zero offset too. `docs/session-v1.schema.json` + (`automation_offset`, `offset` required), `docs/SESSION_SCHEMA_V1.md` (the offset paragraph and + the code list), the author-session skill (the key and two diagnostic rows). +- **Inert until #1650b.** Nothing reads the field yet: render's `S` does not include it, and + `SET_CONTROL fader_db` still writes the static fader. Its production reader is #1650b (the + offset write, R17-1 = A, `controls::effective` adding the lane's offset first, the list flag), + which lands in the R1 push with #1646 to #1649. `docs/SESSION_SCHEMA_V1.md` says the offset is + stored and checked and not yet heard. + +**Departures and readings.** + +- *`-0.0`.* "A non-zero offset on a row that takes none" is read with the hazard's one-spelling + rule: every lane that takes no offset must be `+0.0` bits, so `-0.0` is refused there as well + (R17-6: "It holds `+0.0` on every entry that is not a fader entry"). On a driven fader lane + `-0.0` is a valid zero. +- *Domain source.* The domain check uses the fader row's own descriptor (`check`), which is + `[-144, 24]`, the VCA offset domain the spec names; one source, so a change of the row's domain + moves the offset domain with it (D3's "the row's own domain"). +- *Paths outside the #1650a list, all mechanical:* `crates/session/src/canonical.rs` (the corpus + model gains the field), `crates/session/tests/json_contract_artifacts.rs` (the schema record + table row), `crates/controls/src/error.rs` (the new code), `crates/controls/src/builtin_rows.rs` + (`FADER_DB` made `pub(crate)`), struct-literal callers + (`crates/host-core/tests/{launch_registry_once,live_delta,stored_automation}.rs`, + `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`), the two + Rust-built JSON documents (`hosts/host-web/src/tests.rs`, + `tools/session-validator/tests/validate.rs`), `sdk/src/core/types.ts` and + `sdk/src/cli/session-request.ts` (the SDK surface), and the re-pin's mirrors + (`scripts/check-protocol-wasm-parity.sh`, `fuzz/corpus/complete-schema-manifest.md`, + `docs/CONTROL_PROTOCOL_CONFORMANCE.md`). +- *Re-pin.* `COMPLETE_SCHEMA_HASH` `0x0c71_96f8_6abe_6d57` -> `0x5eb9_ad2e_d137_f3be`, reason: + #1650 adds the automation offset (the `Automation` message gains required field 4, which the + corpus transaction's `UpsertAutomation` carries); 46 frames still. The capi `pcm_digest` is + unchanged (`bffc21e857b5e134`). + +**Gate 1 (as kept by R17-6) and its tests.** + +- JSON grammar: `crates/session/tests/invalid_matrix.rs` + `automation_offset_is_a_required_two_lane_record` (missing record, missing lane, unknown key, + wrong type; a `3.5` / `-0.0` offset round-trips through the canonical writer with its bits). +- Wire: `crates/protocol/src/session_wire/tests.rs` + `every_route_and_automation_opcode_round_trips_canonically` (now with a `2.5` / `-0.0` offset) + and `automation_offset_is_two_required_finite_lanes`; the corpus round trip + (`crates/session/src/canonical.rs` `canonical_writer_corpus_is_rust_generated_and_current`). +- Row rule: `crates/controls/tests/automation.rs` `the_offset_belongs_to_a_driven_fader_lane` + (driven fader lanes and the domain ends accepted; a non-driven lane, `-0.0` on a non-driven lane, + out of domain, a trim, a send gain and a pan entry refused at their paths and codes). +- SDK: `sdk/test/builder-evals.mjs` "issue #1650 -- the builder authors an automation entry's + offset" (three tests, the third through the engine's `validate()`). + +**Checks run.** `cargo test --locked -p session -p protocol -p controls` (378 passed); +`-p host-core --all-features --test live_delta --test launch_registry_once --test stored_automation` +(69); `-p capi` (197); `-p host-web --features host-web/test-support --test control_parity` (21) +and `--lib dense` (2); `-p session-validator` (19); `-p conformance`; `-p parameter-metadata` (20); +`check-sdk-generated.sh`, `check-sdk-types.sh`, `check-protocol-wasm-parity.sh` (and +`--self-test`), `check-command-reason-vocabulary.py` (no change: `control.automation.offset` is a +control diagnostic code, not a `COMMAND_REASON`), `check-builtins-fixtures.sh`, +`check-console-fixtures.sh`, `check-workspace-policy.sh`; SDK `builder-evals.mjs` (56), +`console-evals`, `structure-evals`, `boot-evals`, `live-controls-evals` (131) against a locally +built AudioWorklet module; `audit capi` (`pcm_digest` `bffc21e857b5e134`, 0 violations), `audit +protocol` ok; fmt, clippy `-D warnings` (all features) and the no-feature `cargo check` on the +touched crates. diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 04e603338..9a9e70324 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -2411,6 +2411,7 @@ fn model_only_edits(model: &SessionModel) -> [(&'static str, SessionEdit); 5] { end_value: -3.0, unit: session::ParameterUnit::Db, }], + offset: session::AutomationOffset::default(), }, }, ), @@ -4436,6 +4437,7 @@ fn eq_automation_edit( end_value: 1.0, unit, }], + offset: session::AutomationOffset::default(), }, } } @@ -4542,6 +4544,7 @@ fn builtin_automation_edit( end_value, unit, }], + offset: session::AutomationOffset::default(), }, } } @@ -8010,6 +8013,7 @@ mod set_control { end_value: 0.5, unit: session::ParameterUnit::Linear, }], + offset: session::AutomationOffset::default(), }, }; assert_eq!( @@ -8590,6 +8594,7 @@ fn with_fader_automation(document: &str) -> String { end_value: -3.0, unit: session::ParameterUnit::Db, }], + offset: session::AutomationOffset::default(), }); }) } diff --git a/crates/conformance/src/protocol_corpus.rs b/crates/conformance/src/protocol_corpus.rs index 321446d4e..1c2886c56 100644 --- a/crates/conformance/src/protocol_corpus.rs +++ b/crates/conformance/src/protocol_corpus.rs @@ -925,7 +925,10 @@ pub enum ConformanceDecoder { /// refused and never reallocated, so `command.transport_set` carries the state alone and the /// snapshot and event frames carry the state and the effective sample. The frame count stays 46. /// Batch 13b carries #1678 and #1635 together and re-pins it once from `cc9a7bcc502c4936`. -pub const COMPLETE_SCHEMA_HASH: u64 = 0x0c71_96f8_6abe_6d57; +/// #1650 adds the automation offset: the `Automation` message gains a required field 4, the +/// nested `AutomationOffset` (`left_db` 1, `right_db` 2), so the transaction's `UpsertAutomation` +/// carries it. Re-pinned from `0c7196f86abe6d57`; the frame count stays 46. +pub const COMPLETE_SCHEMA_HASH: u64 = 0x5eb9_ad2e_d137_f3be; /// Build every command, successful response, registered non-OK status, event, and all-opcode /// session transaction using only public typed encoder entry points. diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index fd6971bbd..203687863 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -9,7 +9,7 @@ use session::{ }; use crate::ControlKind; -use crate::builtin_rows::{HPF_HZ, LPF_HZ}; +use crate::builtin_rows::{FADER_DB, HPF_HZ, LPF_HZ}; use crate::descriptor::{ControlDescriptor, Lanes, check}; use crate::effect_rows::{has_rows, session_unit}; use crate::error::ControlError; @@ -45,12 +45,52 @@ fn effect_has_no_rows(model: &SessionModel, target: &ControlAddress) -> bool { identity.is_some_and(|identity| !has_rows(identity)) } +/// Whether the row at `target` takes a stored hand-move offset (#1650 D2, root's ruling R17-6): +/// only a strip's builtin `fader_db`. The slices that render the input trim (#1658) and the send +/// gain (#1654) add their rows here. +pub(crate) fn takes_offset(target: &ControlAddress) -> bool { + matches!(target.owner, Owner::Strip(_)) + && target.section == Section::Builtins + && target.control == FADER_DB +} + +/// The offset rule of one entry whose target is accepted (#1650 D2), per lane, left then right: +/// on a lane that takes no offset (the entry does not drive it, or its row takes none) the +/// offset is exactly `+0.0`, else `control.automation.offset`; on a driven lane of a row that +/// takes one, the offset lies in the row's own domain (`fader_db`: `[-144, 24]` dB, the VCA +/// offset domain), else `control.domain`. `-0.0` is a valid zero only on a lane that takes an +/// offset, so a lane without one has one spelling. +fn offset_faults( + automation: &Automation, + descriptor: &ControlDescriptor, +) -> impl Iterator { + let target = &automation.target; + let takes = takes_offset(target); + [ + (Lane::Left, automation.offset.left_db, ".offset.left_db"), + (Lane::Right, automation.offset.right_db, ".offset.right_db"), + ] + .into_iter() + .filter_map(move |(lane, value, suffix)| { + let driven = target.lane == lane || target.lane == Lane::Both; + let fault = if !(takes && driven) { + (value.to_bits() != 0.0_f32.to_bits()).then_some(ControlError::AutomationOffset) + } else { + check(descriptor, lane, value, None) + .err() + .map(|_| ControlError::Domain) + }; + fault.map(|error| (error, suffix)) + }) +} + /// One refusal of a stored automation: a stable code and the session path it belongs to. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ControlDiagnostic { /// The stable machine code (a [`ControlError`] code). pub code: &'static str, - /// `$.automation[id=].target`, or `$.automation[id=].segments[]`. + /// `$.automation[id=].target`, `$.automation[id=].segments[]`, or + /// `$.automation[id=].offset.left_db` / `.right_db`. pub path: String, } @@ -74,7 +114,11 @@ pub struct ControlDiagnostic { /// - `control.automation.order`: on a strip lane where the HPF or the LPF is automated, the two /// curves can come to an HPF at or above an enabled LPF (#1645 D2; see /// `input_filter_order`). It is reported once per lane, at the LPF entry's segment when the LPF -/// is automated, else at the HPF entry's. +/// is automated, else at the HPF entry's; +/// - `control.automation.offset`: a hand-move offset (#1650 D2) that is not `+0.0` on a lane the +/// entry does not drive, or on any lane of a row that takes no offset (only `fader_db` takes +/// one); `control.domain` for an offset outside the fader row's domain. Each is reported at +/// `.offset.left_db` or `.offset.right_db`, after the entry's segments. /// /// A target is reported once, at `.target`; its segments are judged only when the target is /// accepted. A segment is reported once, at `.segments[]`: a domain fault first, else a shape @@ -144,6 +188,9 @@ pub fn automation_diagnostics(model: &SessionModel, sample_rate: u32) -> Vec before; } for strip in model.strips() { diff --git a/crates/controls/src/builtin_rows.rs b/crates/controls/src/builtin_rows.rs index 4adf0702a..2ccd42c96 100644 --- a/crates/controls/src/builtin_rows.rs +++ b/crates/controls/src/builtin_rows.rs @@ -21,7 +21,7 @@ const POLARITY_INVERT: u32 = 1; const TRIM_DB: u32 = 2; pub(crate) const HPF_HZ: u32 = 3; pub(crate) const LPF_HZ: u32 = 4; -const FADER_DB: u32 = 5; +pub(crate) const FADER_DB: u32 = 5; const MUTE: u32 = 6; const MATRIX_LL: u32 = 7; const MATRIX_LR: u32 = 8; diff --git a/crates/controls/src/error.rs b/crates/controls/src/error.rs index e1471076a..dd6340811 100644 --- a/crates/controls/src/error.rs +++ b/crates/controls/src/error.rs @@ -34,6 +34,11 @@ pub enum ControlError { /// value) can bring the HPF to or above an enabled LPF (#1645 D2). Raised by /// [`automation_diagnostics`](crate::automation_diagnostics) only. AutomationOrder, + /// `control.automation.offset`: a stored automation's hand-move offset (#1650 D2) that is not + /// `+0.0` on a lane where no offset is taken: a lane the entry does not drive, or any lane of + /// an entry whose row takes no offset (only `fader_db` takes one). Raised by + /// [`automation_diagnostics`](crate::automation_diagnostics) only. + AutomationOffset, /// `control.ramp.fixed`: an explicit ramp length on a row whose ramp is fixed (an /// acknowledgement must not hide an ignored field). RampFixed, @@ -66,6 +71,7 @@ impl ControlError { Self::AutomationTarget => "control.automation.target", Self::AutomationShape => "control.automation.shape", Self::AutomationOrder => "control.automation.order", + Self::AutomationOffset => "control.automation.offset", Self::RampFixed => "control.ramp.fixed", Self::Monitor => "control.monitor", Self::RampConflict => "control.ramp.conflict", diff --git a/crates/controls/tests/automation.rs b/crates/controls/tests/automation.rs index b02c9927d..e3d412f38 100644 --- a/crates/controls/tests/automation.rs +++ b/crates/controls/tests/automation.rs @@ -5,9 +5,13 @@ mod common; use common::{RATE, address, effects_fixture, id, insert_id, route_own, strip_builtin, strip_own}; use controls::{ControlAddress, ControlDiagnostic, Lane, Owner, Section, automation_diagnostics}; -use session::{Automation, AutomationSegment, AutomationShape, ParameterUnit, SessionModel}; +use session::{ + Automation, AutomationOffset, AutomationSegment, AutomationShape, ParameterUnit, SessionModel, +}; +const TRIM_DB: u32 = 2; const FADER_DB: u32 = 5; +const PAN: u32 = 12; const DELAY_SAMPLES: u32 = 11; const MATRIX_LL: u32 = 7; const SOLO: u32 = 2; @@ -37,6 +41,7 @@ fn automation(name: &str, target: ControlAddress, segments: Vec Automation { + Automation { + offset: AutomationOffset { left_db, right_db }, + ..automation + } +} + +/// #1650a gate 1 (D2, root's ruling R17-6): the hand-move offset belongs to a lane the entry +/// drives on the one row that takes it, `fader_db`, inside that row's `[-144, 24]` dB. A lane the +/// entry does not drive, and every lane of any other row (the input trim and the send gain until +/// #1658 and #1654 add them, a pan), hold exactly `+0.0`, else `control.automation.offset`; an +/// offset outside the domain is `control.domain`. Each is reported at its lane's path, after the +/// entry's segments. +/// +/// Red if `takes_offset` admits another row (the stored value would be accepted and never heard), +/// if the lane rule is missing (a `left` entry could carry a right offset that no lane reads), if +/// the domain check is missing, or if `-0.0` passes on a lane that takes no offset (two spellings +/// of "none"). +#[test] +fn the_offset_belongs_to_a_driven_fader_lane() { + let fader = |name: &str, lane: Lane| { + one( + name, + strip_builtin("vocal", FADER_DB, lane), + ParameterUnit::Db, + -6.0, + ) + }; + // Accepted: a both entry with one offset per lane, a left entry with a left offset, the domain + // ends, and a `-0.0` zero on a driven lane. + for accepted in [ + with_offset(fader("both", Lane::Both), 3.0, -6.0), + with_offset(fader("left", Lane::Left), 24.0, 0.0), + with_offset(fader("right", Lane::Right), 0.0, -144.0), + with_offset(fader("negative-zero", Lane::Both), -0.0, -0.0), + ] { + let name = accepted.id.as_str().to_owned(); + assert_eq!(judged(vec![accepted]), [], "{name}"); + } + + let refused = [ + // The lane rule: a lane the entry does not drive holds `+0.0`, and only `+0.0`. + ( + with_offset(fader("left-right", Lane::Left), 0.0, 1.0), + "control.automation.offset", + ".offset.right_db", + ), + ( + with_offset(fader("right-left", Lane::Right), -0.0, 0.0), + "control.automation.offset", + ".offset.left_db", + ), + // The fader row's domain. + ( + with_offset(fader("above", Lane::Both), 24.5, 0.0), + "control.domain", + ".offset.left_db", + ), + ( + with_offset(fader("below", Lane::Left), -144.5, 0.0), + "control.domain", + ".offset.left_db", + ), + // Rows that take no offset (R17-6): the trim, a send's gain, a pan. + ( + with_offset( + one( + "trim", + strip_builtin("vocal", TRIM_DB, Lane::Left), + ParameterUnit::Db, + 0.0, + ), + 2.0, + 0.0, + ), + "control.automation.offset", + ".offset.left_db", + ), + ( + with_offset( + one( + "send-gain", + route_own("send", GAIN_DB), + ParameterUnit::Db, + 0.0, + ), + 0.0, + -3.0, + ), + "control.automation.offset", + ".offset.right_db", + ), + ( + with_offset( + one( + "pan", + strip_builtin("vocal", PAN, Lane::Both), + ParameterUnit::Linear, + 0.0, + ), + 0.0, + -0.0, + ), + "control.automation.offset", + ".offset.right_db", + ), + ]; + for (entry, code, suffix) in refused { + let name = entry.id.as_str().to_owned(); + assert_eq!( + judged(vec![entry]), + [(code, format!("$.automation[id={name}]{suffix}"))], + "{name}" + ); + } + + // Both lanes report, after a segment fault of the same entry. + let mut both = with_offset( + one( + "trim-both", + strip_builtin("vocal", TRIM_DB, Lane::Both), + ParameterUnit::Db, + 0.0, + ), + 1.0, + 1.0, + ); + both.segments[0].unit = ParameterUnit::Hz; + assert_eq!( + judged(vec![both]), + [ + ( + "control.domain", + "$.automation[id=trim-both].segments[0]".to_owned() + ), + ( + "control.automation.offset", + "$.automation[id=trim-both].offset.left_db".to_owned() + ), + ( + "control.automation.offset", + "$.automation[id=trim-both].offset.right_db".to_owned() + ), + ] + ); +} diff --git a/crates/host-core/tests/launch_registry_once.rs b/crates/host-core/tests/launch_registry_once.rs index c1f017329..79ead1790 100644 --- a/crates/host-core/tests/launch_registry_once.rs +++ b/crates/host-core/tests/launch_registry_once.rs @@ -113,6 +113,7 @@ fn classify_automation_change(current: &SessionModel) { end_value: 1.0, unit: ParameterUnit::Db, }], + offset: session::AutomationOffset::default(), }); let delta = classify_live_delta(current, &next, STEP, &EditRamps::new()) .expect("a block-rate EQ ride is live"); diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 2b8342be3..e8484d53a 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -1117,6 +1117,7 @@ fn fader_ride(model: &SessionModel) -> Automation { end_value: -3.0, unit: ParameterUnit::Db, }], + offset: session::AutomationOffset::default(), } } @@ -3246,6 +3247,7 @@ fn eq_automation(model: &SessionModel, parameter_id: u32, unit: ParameterUnit) - end_value: 1.0, unit, }], + offset: session::AutomationOffset::default(), } } diff --git a/crates/host-core/tests/stored_automation.rs b/crates/host-core/tests/stored_automation.rs index c445e552d..dd96d78f3 100644 --- a/crates/host-core/tests/stored_automation.rs +++ b/crates/host-core/tests/stored_automation.rs @@ -58,6 +58,7 @@ fn with_eq_automation(control: u32, unit: ParameterUnit) -> SessionModel { end_value: 1.0, unit, }], + offset: session::AutomationOffset::default(), }]; model } diff --git a/crates/protocol/src/schema.rs b/crates/protocol/src/schema.rs index 4477a4893..b8ade75be 100644 --- a/crates/protocol/src/schema.rs +++ b/crates/protocol/src/schema.rs @@ -1318,6 +1318,15 @@ pub(crate) mod session { ], }; } + pub(crate) mod automation_offset { + use super::*; + pub(crate) const LEFT_DB: FieldSpec = FieldSpec::req(1, Wire::F32); + pub(crate) const RIGHT_DB: FieldSpec = FieldSpec::req(2, Wire::F32); + pub(crate) static SPEC: MessageSpec = MessageSpec { + name: "AutomationOffset", + fields: &[LEFT_DB, RIGHT_DB], + }; + } pub(crate) mod automation { use super::*; pub(crate) const ID: FieldSpec = FieldSpec::req(1, Wire::Utf8); @@ -1326,9 +1335,12 @@ pub(crate) mod session { FieldSpec::msg(2, true, false, &crate::schema::control_address::SPEC); pub(crate) const SEGMENT: FieldSpec = FieldSpec::msg(3, true, true, &automation_segment::SPEC); + // The stored hand-move offset (#1650 D1): required, appended after the segments. + pub(crate) const OFFSET: FieldSpec = + FieldSpec::msg(4, true, false, &automation_offset::SPEC); pub(crate) static SPEC: MessageSpec = MessageSpec { name: "Automation", - fields: &[ID, TARGET, SEGMENT], + fields: &[ID, TARGET, SEGMENT, OFFSET], }; } @@ -1761,17 +1773,17 @@ mod tests { use std::collections::BTreeSet; use super::session::{ - automation, automation_segment, builtins, channel_builtins, channel_matrix, console, - console_entry, console_slot, effect, effect_identity, fader, matrix_or_pan, output, - output_profile, param, rack, render_profile, route, route_destination, route_source, - sidechain, source, submix, track, vca, + automation, automation_offset, automation_segment, builtins, channel_builtins, + channel_matrix, console, console_entry, console_slot, effect, effect_identity, fader, + matrix_or_pan, output, output_profile, param, rack, render_profile, route, + route_destination, route_source, sidechain, source, submix, track, vca, }; // (keys module, its wire message, IDs the wire carries with no key). `None` is a keys // module with no wire message of its own: `control_smoothing` is written only by the // `SET_CONTROL` row of the session control table and never framed as a session record. // The `matrix_or_pan` tag (field 1) is wire-only: the walk emits it as `wire_tag`. - let table: [(&str, Option<&MessageSpec>, &[u16]); 27] = [ + let table: [(&str, Option<&MessageSpec>, &[u16]); 28] = [ ("console", Some(&console::SPEC), &[]), ("console_slot", Some(&console_slot::SPEC), &[]), ("console_entry", Some(&console_entry::SPEC), &[]), @@ -1797,6 +1809,7 @@ mod tests { ("route_destination", Some(&route_destination::SPEC), &[]), ("channel_matrix", Some(&channel_matrix::SPEC), &[]), ("automation", Some(&automation::SPEC), &[]), + ("automation_offset", Some(&automation_offset::SPEC), &[]), // An automation target is a control address (#1566): the keys are its field IDs. ("target", Some(&control_address::SPEC), &[]), ("segment", Some(&automation_segment::SPEC), &[]), diff --git a/crates/protocol/src/session_wire.rs b/crates/protocol/src/session_wire.rs index a51d2ad76..2fb9b5613 100644 --- a/crates/protocol/src/session_wire.rs +++ b/crates/protocol/src/session_wire.rs @@ -4,11 +4,11 @@ //! the same field specifications used by validation and decoding. use session::{ - Automation, AutomationSegment, AutomationShape, ChannelBuiltins, ChannelMatrix, Console, - ConsoleEntry, ConsoleSlot, DualMonoBuiltins, DualMonoFader, Effect, EffectIdentity, - EffectParam, EffectQuality, MatrixOrPan, Output, OutputProfile, Rack, RackName, RenderProfile, - Route, RouteDestination, RouteSource, SidechainDeclaration, Source, SourceBitDepth, StableId, - Submix, Vca, + Automation, AutomationOffset, AutomationSegment, AutomationShape, ChannelBuiltins, + ChannelMatrix, Console, ConsoleEntry, ConsoleSlot, DualMonoBuiltins, DualMonoFader, Effect, + EffectIdentity, EffectParam, EffectQuality, MatrixOrPan, Output, OutputProfile, Rack, RackName, + RenderProfile, Route, RouteDestination, RouteSource, SidechainDeclaration, Source, + SourceBitDepth, StableId, Submix, Vca, }; use crate::{ @@ -1081,7 +1081,25 @@ fn tx_automation(sink: &mut dyn Sink, value: &Automation) -> Result<(), EncodeEr tx_automation_segment(v, segment) })?; } - Ok(()) + tx_message(sink, schema::session::automation::OFFSET, |v| { + tx_automation_offset(v, &value.offset) + }) +} +fn tx_automation_offset(sink: &mut dyn Sink, value: &AutomationOffset) -> Result<(), EncodeError> { + tx_start_message( + sink, + schema::session::automation_offset::SPEC.field_count(&[])?, + )?; + tx_f32( + sink, + schema::session::automation_offset::LEFT_DB, + value.left_db, + )?; + tx_f32( + sink, + schema::session::automation_offset::RIGHT_DB, + value.right_db, + ) } fn parse_edit(message: Message<'_>) -> Result { @@ -1729,6 +1747,22 @@ fn parse_automation(message: Message<'_>) -> Result { segments: values_spec!(message, schema::session::automation::SEGMENT)? .map(|value| parse_automation_segment(message.nested_value(value)?)) .collect::, _>>()?, + offset: parse_automation_offset( + message.nested_value(one_spec!(message, schema::session::automation::OFFSET)?)?, + )?, + }) +} +fn parse_automation_offset(message: Message<'_>) -> Result { + let message = message.schema_spec(&schema::session::automation_offset::SPEC)?; + Ok(AutomationOffset { + left_db: read_f32_exact(one_spec!( + message, + schema::session::automation_offset::LEFT_DB + )?)?, + right_db: read_f32_exact(one_spec!( + message, + schema::session::automation_offset::RIGHT_DB + )?)?, }) } fn parse_track_effect_ref( diff --git a/crates/protocol/src/session_wire/tests.rs b/crates/protocol/src/session_wire/tests.rs index d6174210e..4c41c5f07 100644 --- a/crates/protocol/src/session_wire/tests.rs +++ b/crates/protocol/src/session_wire/tests.rs @@ -1281,6 +1281,12 @@ fn every_route_and_automation_opcode_round_trips_canonically() { unit: ParameterUnit::Ratio, }, ], + // #1650 D1: a stored offset crosses the codec as field 4. Distinct lanes and a `-0.0` + // catch a codec that swaps the two fields or drops a zero's sign. + offset: session::AutomationOffset { + left_db: 2.5, + right_db: -0.0, + }, }; let edits = vec![ SessionEdit::UpsertSubmix { @@ -2124,3 +2130,43 @@ fn an_automation_target_cannot_be_owned_by_the_session() { Err(EncodeError::InvalidField) ); } + +/// #1650 D1: the automation offset is a required nested message of two required finite `F32` +/// lanes, field 1 left and field 2 right. +/// +/// Red if a lane becomes optional, if a non-finite offset decodes, or if the decoder swaps the +/// two lanes. +#[test] +fn automation_offset_is_two_required_finite_lanes() { + let lane = |value: f32| value.to_le_bytes().to_vec(); + let decode = |fields: Vec<(u16, u8, bool, Vec)>| { + let bytes = raw_message(fields); + parse_automation_offset(Message::nested(&bytes).expect("nested offset")) + }; + assert_eq!( + decode(vec![ + (1, WIRE_F32, true, lane(-1.5)), + (2, WIRE_F32, true, lane(3.0)), + ]), + Ok(AutomationOffset { + left_db: -1.5, + right_db: 3.0, + }) + ); + assert!( + decode(vec![(1, WIRE_F32, true, lane(0.0))]).is_err(), + "a missing right lane" + ); + assert!( + decode(vec![(2, WIRE_F32, true, lane(0.0))]).is_err(), + "a missing left lane" + ); + assert!( + decode(vec![ + (1, WIRE_F32, true, lane(f32::NAN)), + (2, WIRE_F32, true, lane(0.0)), + ]) + .is_err(), + "a non-finite lane" + ); +} diff --git a/crates/session/src/canonical.rs b/crates/session/src/canonical.rs index 9bb0a09b1..59f30e834 100644 --- a/crates/session/src/canonical.rs +++ b/crates/session/src/canonical.rs @@ -279,11 +279,12 @@ mod tests { use super::{ByteCount, Sink, write_quoted}; use crate::value::write_f32; use crate::{ - Automation, AutomationSegment, AutomationShape, ChannelBuiltins, ChannelMatrix, Console, - ConsoleEntry, ConsoleSlot, ControlAddress, DualMonoBuiltins, DualMonoFader, Effect, - EffectIdentity, EffectParam, EffectQuality, LinkMode, MatrixOrPan, Owner, ParameterChannel, - ParameterUnit, Rack, Route, RouteDestination, RouteSource, Section, SendTap, Sidechain, - SidechainDeclaration, StableId, canonical_session_json, parse_session_json, + Automation, AutomationOffset, AutomationSegment, AutomationShape, ChannelBuiltins, + ChannelMatrix, Console, ConsoleEntry, ConsoleSlot, ControlAddress, DualMonoBuiltins, + DualMonoFader, Effect, EffectIdentity, EffectParam, EffectQuality, LinkMode, MatrixOrPan, + Owner, ParameterChannel, ParameterUnit, Rack, Route, RouteDestination, RouteSource, + Section, SendTap, Sidechain, SidechainDeclaration, StableId, canonical_session_json, + parse_session_json, }; #[test] fn canonical_string_escaping_is_frozen() { @@ -566,6 +567,7 @@ mod tests { end_value: -3.0, unit: ParameterUnit::Db, }], + offset: AutomationOffset::default(), }; model.automation.insert( 0, @@ -588,15 +590,22 @@ mod tests { lane: ParameterChannel::Both, }, )); - model.automation.push(ride( - "fader-ride", - ControlAddress { - owner: Owner::Strip(id("vocal")), - section: Section::Builtins, - control: 5, - lane: ParameterChannel::Left, + // A hand move stored on the left fader ride (#1650 D1): the corpus walks a non-zero offset. + model.automation.push(Automation { + offset: AutomationOffset { + left_db: 3.0, + right_db: 0.0, }, - )); + ..ride( + "fader-ride", + ControlAddress { + owner: Owner::Strip(id("vocal")), + section: Section::Builtins, + control: 5, + lane: ParameterChannel::Left, + }, + ) + }); model.automation.push(ride( "desk-gain", ControlAddress { diff --git a/crates/session/src/model.rs b/crates/session/src/model.rs index 4169e0420..596d1ffbc 100644 --- a/crates/session/src/model.rs +++ b/crates/session/src/model.rs @@ -982,6 +982,22 @@ pub struct Automation { pub target: ControlAddress, /// Ordered segments, preserved canonically. pub segments: Vec, + /// The stored hand-move offset of each lane, in dB (#1650 D1). Required in every entry: the + /// V1 grammar has no optional key. Each lane's offset belongs to the entry that drives that + /// lane, so a lane this entry does not drive holds `+0.0`, and only a row that takes an offset + /// may hold a non-zero value (`controls::takes_offset`). The session crate cannot see rows and + /// checks nothing here; `controls::automation_diagnostics` judges it. + pub offset: AutomationOffset, +} + +/// The per-lane dB offset a hand move adds to an automated fader (#1650 D1). The default is no +/// offset: `+0.0` on both lanes. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct AutomationOffset { + /// The left lane's offset in dB. + pub left_db: f32, + /// The right lane's offset in dB. + pub right_db: f32, } closed_tokens! { diff --git a/crates/session/src/parse.rs b/crates/session/src/parse.rs index 53738dc5f..d3d62734d 100644 --- a/crates/session/src/parse.rs +++ b/crates/session/src/parse.rs @@ -1,7 +1,7 @@ //! Explicit typed walk over a strict, spanned JSON value tree. use crate::{ - Automation, AutomationSegment, CONTROL_SMOOTHING_MS_MAXIMUM, ChannelBuiltins, ChannelMatrix, - ControlAddress, ControlSmoothing, Diagnostic, DiagnosticCode, + Automation, AutomationOffset, AutomationSegment, CONTROL_SMOOTHING_MS_MAXIMUM, ChannelBuiltins, + ChannelMatrix, ControlAddress, ControlSmoothing, Diagnostic, DiagnosticCode, DiagnosticPath as OwnedDiagnosticPath, DiagnosticSet, DualMonoBuiltins, DualMonoFader, Effect, EffectIdentity, EffectParam, MatrixOrPan, Output, OutputProfile, Owner, Rack, RenderProfile, Route, RouteDestination, RouteSource, SESSION_SCHEMA_VERSION_V1, Section, SessionModel, @@ -1571,14 +1571,32 @@ fn parse_automation( table: TableRef<'_>, path: DiagnosticPath, ) -> Option { - parser.keys(table, &["id", "target", "segments"], &path); + parser.keys(table, &["id", "target", "segments", "offset"], &path); let id = parser.id(table, "id", &path); let target = parse_record(parser, table, "target", &path, parse_target); let segments = parse_list(parser, table, "segments", &path, parse_segment); + let offset = parse_record(parser, table, "offset", &path, parse_automation_offset); Some(Automation { id: id?, target: target?, segments: segments?, + offset: offset?, + }) +} + +/// The stored hand-move offset (#1650 D1): two required finite `f32` dB values. Its domain and +/// lane rule need the row, so `controls::automation_diagnostics` checks them, not the grammar. +fn parse_automation_offset( + parser: &mut Parser, + table: TableRef<'_>, + path: DiagnosticPath, +) -> Option { + parser.keys(table, &["left_db", "right_db"], &path); + let left_db = parser.f32(table, "left_db", &path); + let right_db = parser.f32(table, "right_db", &path); + Some(AutomationOffset { + left_db: left_db?, + right_db: right_db?, }) } diff --git a/crates/session/src/visit.rs b/crates/session/src/visit.rs index b22fb200c..4da938eb8 100644 --- a/crates/session/src/visit.rs +++ b/crates/session/src/visit.rs @@ -140,7 +140,9 @@ pub mod keys { (route_source,"Route-source fields.";KIND="kind":1,TRACK_ID="track_id":2,SUBMIX_ID="submix_id":2,TAP="tap":3) (route_destination,"Route-destination fields.";KIND="kind":1,SUBMIX_ID="submix_id":2,OUTPUT_ID="output_id":2) (channel_matrix,"Channel-matrix fields.";LL="ll":1,LR="lr":2,RL="rl":3,RR="rr":4) - (automation,"Automation fields.";ID="id":1,TARGET="target":2,SEGMENTS="segments":3) + // `offset` is appended as 4 (#1650 D1). + (automation,"Automation fields.";ID="id":1,TARGET="target":2,SEGMENTS="segments":3,OFFSET="offset":4) + (automation_offset,"Automation-offset fields (#1650).";LEFT_DB="left_db":1,RIGHT_DB="right_db":2) // An automation target is a control address (#1566): the ids are `control_address`'s wire field ids. `slot_id` is present in the `console` and `inserts` arms only. (target,"Automation-target fields (a control address).";OWNER="owner":1,OWNER_ID="owner_id":2,SECTION="section":3,SLOT_ID="slot_id":4,CONTROL="control":5,LANE="lane":6) (segment,"Automation-segment fields.";SHAPE="shape":1,START_SAMPLE="start_sample":2,END_SAMPLE="end_sample":3,START_VALUE="start_value":4,END_VALUE="end_value":5,UNIT="unit":6) @@ -296,7 +298,8 @@ mod walk { Route=>route |s,v,o,f| [7] { v.id(f::ID,&s.id),s.source.record(Some(f::SOURCE),o,v),s.destination.record(Some(f::DESTINATION),o,v),s.channel_matrix.record(Some(f::CHANNEL_MATRIX),o,v),v.f32(f::GAIN_DB,s.gain_db),v.bool(f::MUTE,s.mute),v.bool(f::FOLLOWS_MUTE,s.follows_mute) } - Automation=>automation |s,v,o,f| [2+s.segments.len() as u32] {v.id(f::ID,&s.id),s.target.record(Some(f::TARGET),o,v),array(f::SEGMENTS,&s.segments,o,v)} + Automation=>automation |s,v,o,f| [3+s.segments.len() as u32] {v.id(f::ID,&s.id),s.target.record(Some(f::TARGET),o,v),array(f::SEGMENTS,&s.segments,o,v),s.offset.record(Some(f::OFFSET),o,v)} + AutomationOffset=>automation_offset |s,v,_o,f| [2] {v.f32(f::LEFT_DB,s.left_db),v.f32(f::RIGHT_DB,s.right_db)} AutomationSegment=>segment |s,v,_o,f| [6] { v.token(f::SHAPE,token(s.shape.token(),s.shape.wire())),v.u64(f::START_SAMPLE,s.start_sample),v.u64(f::END_SAMPLE,s.end_sample), v.f32(f::START_VALUE,s.start_value),v.f32(f::END_VALUE,s.end_value),v.token(f::UNIT,token(s.unit.token(),s.unit.wire())) diff --git a/crates/session/tests/invalid_matrix.rs b/crates/session/tests/invalid_matrix.rs index 3640bce79..af7f907b9 100644 --- a/crates/session/tests/invalid_matrix.rs +++ b/crates/session/tests/invalid_matrix.rs @@ -1113,6 +1113,78 @@ fn automation_category_has_20_distinct_cases() { assert_eq!(count, 20); } +/// Issue #1650 D1: every automation entry carries a required `offset` record of two required +/// `f32` dB lanes, written after `segments`. The grammar refuses a missing record, a missing lane +/// and an unknown key at their paths; the row rule (domain, lane, `takes_offset`) is +/// `controls::automation_diagnostics`'s and is tested there. +/// +/// Red if the key becomes optional, if the record accepts a third key, or if the canonical writer +/// drops an offset or the sign of a `-0.0` lane. +#[test] +fn automation_offset_is_a_required_two_lane_record() { + const ZERO: &str = r#","offset":{"left_db":0.0,"right_db":0.0}"#; + let compact = compact_json(EXAMPLE); + assert_eq!( + compact.matches(ZERO).count(), + 1, + "the fixture carries one zero offset" + ); + let mut count = 0; + parse_case( + &mut count, + &compact.replacen(ZERO, "", 1), + DiagnosticCode::MissingField, + "$.automation[0].offset", + ); + parse_case( + &mut count, + &compact.replacen(ZERO, r#","offset":{"left_db":0.0}"#, 1), + DiagnosticCode::MissingField, + "$.automation[0].offset.right_db", + ); + parse_case( + &mut count, + &compact.replacen( + ZERO, + r#","offset":{"left_db":0.0,"right_db":0.0,"center_db":0.0}"#, + 1, + ), + DiagnosticCode::UnknownField, + "$.automation[0].offset.center_db", + ); + parse_case( + &mut count, + &compact.replacen(ZERO, r#","offset":{"left_db":"3","right_db":0.0}"#, 1), + DiagnosticCode::WrongType, + "$.automation[0].offset.left_db", + ); + assert_eq!(count, 4); + + let moved = parse_session_json(&compact.replacen( + ZERO, + r#","offset":{"left_db":3.5,"right_db":-0.0}"#, + 1, + )) + .expect("a stored offset parses"); + assert_eq!(moved.automation[0].offset.left_db, 3.5); + assert_eq!( + moved.automation[0].offset.right_db.to_bits(), + (-0.0f32).to_bits() + ); + let canonical = canonical_session_json(&moved).expect("a stored offset canonicalizes"); + assert!( + canonical.contains( + "\"offset\": {\n \"left_db\": 3.5,\n \"right_db\": -0.0\n }" + ) + ); + let reparsed = parse_session_json(&canonical).expect("canonical reparses"); + assert_eq!(reparsed, moved); + assert_eq!( + reparsed.automation[0].offset.right_db.to_bits(), + (-0.0f32).to_bits() + ); +} + #[test] fn configured_resource_category_has_4_distinct_cases() { let mut count = 0; diff --git a/crates/session/tests/json_contract_artifacts.rs b/crates/session/tests/json_contract_artifacts.rs index c520ee30e..b584c2d36 100644 --- a/crates/session/tests/json_contract_artifacts.rs +++ b/crates/session/tests/json_contract_artifacts.rs @@ -392,6 +392,7 @@ const SCHEMA_RECORDS: &[(&str, &[(&str, Option<&str>)])] = &[ ], ), ("automation_segment", &[("segment", None)]), + ("automation_offset", &[("automation_offset", None)]), ("automation", &[("automation", None)]), ]; diff --git a/docs/CONTROL_PROTOCOL_CONFORMANCE.md b/docs/CONTROL_PROTOCOL_CONFORMANCE.md index 334c8fabd..2328ec729 100644 --- a/docs/CONTROL_PROTOCOL_CONFORMANCE.md +++ b/docs/CONTROL_PROTOCOL_CONFORMANCE.md @@ -1,6 +1,6 @@ # MISO control protocol conformance record -This manifest records Issue 005 non-benchmark evidence and its limits. The normative corpus is `complete_schema_corpus()`: 46 canonical frames covering 11 commands (including one transaction with all 35 edit opcodes), 13 success responses, 16 non-OK statuses including typed backpressure, and six events. Its FNV-1a-64 label-and-byte hash is `0c7196f86abe6d57` (`COMPLETE_SCHEMA_HASH`; re-pinned once in batch 13b, from `cc9a7bcc502c4936`, for two changes together: the retirement of the stored transport position in #1635 (`TRANSPORT_SET` field 2, the snapshot's field 2 and the `TRANSPORT_STATE` event's field 3 are refused and never reallocated, so the command frame carries the state alone and the snapshot and event frames the state and the effective sample, 46 frames still) and the optional expected effect of the `SET_CONTROL` address in #1678 (the transaction appends a `SetControl` on an insert row that carries it); re-pinned for the fold of `OBSERVATION_ENDED` to one event per commit in #1584 D6a (root ruling A): the event carries a repeated key message, and the corpus frame carries two keys; re-pinned for `OBSERVATION_ENDED` in #1584: the corpus gains `event.observation_ended` (`8011`) and the capabilities response lists event `8011`; re-pinned for the retirement of `AUTOMATION_CANCELED` in #1628: the event `8002` is refused and never reallocated, so the corpus loses `event.automation_canceled` and the capabilities response lists no event `8002`; re-pinned for the retirement of `AUTOMATION_ENQUEUE` in #1627: `0006`, its success response, statuses 15 and 16 and capability fields 9, 12, 19 and 20 are refused and never reallocated, so the corpus loses the command frame, its response and the two status frames and the capabilities response has 23 fields; re-pinned for `RESPONSE_GET` in #1573: `000e`, one command frame and two response frames (a curve reply and a lone tag), and the capability registry lists `000e` with bit 14; re-pinned for the transaction response of the monitor edit path in the batch 4 review fold: the corpus gains `response.session_transaction_monitor`, `edit_path` 4; re-pinned for the retirement of the thirteen per-kind value ops in #1561: opcodes `0008`, `0203`, `020a`, `020b`, `020d`-`0210`, `0504`-`0507` and `0702` are refused and never reallocated, 35 edits; re-pinned for `TransactionApplied` field 2, `edit_path`, in #1313; re-pinned for `SetControl` in #1553: opcode `0800` appended, 48 edits; re-pinned for the control list in #1551: `CONTROL_LIST_GET` and `CONTROL_STATE_GET` (`000c`, `000d`) replace the retired `0004` and `0005`, with a list page of every kind of row and the state page of its control rows, re-pinned for the BLAKE3 source-identity spelling in #787, for decision 12's track encoding in #1093: field 7 carries the inserts, the retired fields 6 and 8 are gone, and field 11 carries the console entries, for decision 12's console edits in #1094: `SetConsole` `0007` and `SetTrackConsole` `0211`, for the submix strip in #1199: submix fields 2, 4, 5, 6, for the submix console in #1202: submix field 3 repeats the console entry, and for the bus tap in #1203: a tag-2 route source carries a required tap, and the corpus's `SetRouteSource` value is a tapped submix source, and for the route switch in #1216: route field `mute` (field 6) and opcode `0506` (`SetRouteMute`), and for the send's follow in #1218: route field `follows_mute` (field 7) and opcode `0507` (`SetRouteFollowsMute`), and for VCA groups in #1241: the VCA message (`id` 1, `fader` 2, repeated `members` 3) and opcodes `0700`-`0702` (`UpsertVca`, `RemoveVca`, `SetVcaFader`), and for the per-edit ramp length in #1394: #1394 appends optional `ramp_samples` to the live-value edits, and the corpus's `SetTrackFader` carries one, and for the session's control smoothing in #1365: #1365 appends `SetControlSmoothing` `0008`, and the corpus's transaction carries one). Decision 12's retired and refused codes each have a wire row in `retired_code_rows()`, and so do the retired message IDs `0004`, `0005`, `0006` and `8002` (#1551, #1627, #1628; `0006` is a frame with the payload the command had, `8002` an event ID under a command header: the decoder's `UNSUPPORTED_MESSAGE`, with no response frame because a message ID that decodes to no message has no safe request identity): rack codes 1 and 3 in every rack-addressed edit and in an automation target, track fields 6 and 8 in both flag forms, `TRANSPORT_SET` field 2 in both flag forms (#1635), each structural or declaration edit addressed at `console`, each of the thirteen value-op codes retired by #1561 (an unallocated opcode, refused at decode), and a `SetTrackConsole` in a transaction with no `SetConsole` before it that changes the console (`session.edit.value_op_retired`); an endpoint over `console_session_fixture()` answers each with its status and commits nothing. Auditable seed frames are checked in under [`fuzz/corpus`](../fuzz/corpus/README.md). +This manifest records Issue 005 non-benchmark evidence and its limits. The normative corpus is `complete_schema_corpus()`: 46 canonical frames covering 11 commands (including one transaction with all 35 edit opcodes), 13 success responses, 16 non-OK statuses including typed backpressure, and six events. Its FNV-1a-64 label-and-byte hash is `5eb9ad2ed137f3be` (`COMPLETE_SCHEMA_HASH`; re-pinned from `0c7196f86abe6d57` for the automation offset in #1650: the `Automation` message gains a required field 4, the nested `AutomationOffset` (`left_db` 1, `right_db` 2), which the transaction's `UpsertAutomation` carries, 46 frames still; re-pinned once in batch 13b, from `cc9a7bcc502c4936`, for two changes together: the retirement of the stored transport position in #1635 (`TRANSPORT_SET` field 2, the snapshot's field 2 and the `TRANSPORT_STATE` event's field 3 are refused and never reallocated, so the command frame carries the state alone and the snapshot and event frames the state and the effective sample, 46 frames still) and the optional expected effect of the `SET_CONTROL` address in #1678 (the transaction appends a `SetControl` on an insert row that carries it); re-pinned for the fold of `OBSERVATION_ENDED` to one event per commit in #1584 D6a (root ruling A): the event carries a repeated key message, and the corpus frame carries two keys; re-pinned for `OBSERVATION_ENDED` in #1584: the corpus gains `event.observation_ended` (`8011`) and the capabilities response lists event `8011`; re-pinned for the retirement of `AUTOMATION_CANCELED` in #1628: the event `8002` is refused and never reallocated, so the corpus loses `event.automation_canceled` and the capabilities response lists no event `8002`; re-pinned for the retirement of `AUTOMATION_ENQUEUE` in #1627: `0006`, its success response, statuses 15 and 16 and capability fields 9, 12, 19 and 20 are refused and never reallocated, so the corpus loses the command frame, its response and the two status frames and the capabilities response has 23 fields; re-pinned for `RESPONSE_GET` in #1573: `000e`, one command frame and two response frames (a curve reply and a lone tag), and the capability registry lists `000e` with bit 14; re-pinned for the transaction response of the monitor edit path in the batch 4 review fold: the corpus gains `response.session_transaction_monitor`, `edit_path` 4; re-pinned for the retirement of the thirteen per-kind value ops in #1561: opcodes `0008`, `0203`, `020a`, `020b`, `020d`-`0210`, `0504`-`0507` and `0702` are refused and never reallocated, 35 edits; re-pinned for `TransactionApplied` field 2, `edit_path`, in #1313; re-pinned for `SetControl` in #1553: opcode `0800` appended, 48 edits; re-pinned for the control list in #1551: `CONTROL_LIST_GET` and `CONTROL_STATE_GET` (`000c`, `000d`) replace the retired `0004` and `0005`, with a list page of every kind of row and the state page of its control rows, re-pinned for the BLAKE3 source-identity spelling in #787, for decision 12's track encoding in #1093: field 7 carries the inserts, the retired fields 6 and 8 are gone, and field 11 carries the console entries, for decision 12's console edits in #1094: `SetConsole` `0007` and `SetTrackConsole` `0211`, for the submix strip in #1199: submix fields 2, 4, 5, 6, for the submix console in #1202: submix field 3 repeats the console entry, and for the bus tap in #1203: a tag-2 route source carries a required tap, and the corpus's `SetRouteSource` value is a tapped submix source, and for the route switch in #1216: route field `mute` (field 6) and opcode `0506` (`SetRouteMute`), and for the send's follow in #1218: route field `follows_mute` (field 7) and opcode `0507` (`SetRouteFollowsMute`), and for VCA groups in #1241: the VCA message (`id` 1, `fader` 2, repeated `members` 3) and opcodes `0700`-`0702` (`UpsertVca`, `RemoveVca`, `SetVcaFader`), and for the per-edit ramp length in #1394: #1394 appends optional `ramp_samples` to the live-value edits, and the corpus's `SetTrackFader` carries one, and for the session's control smoothing in #1365: #1365 appends `SetControlSmoothing` `0008`, and the corpus's transaction carries one). Decision 12's retired and refused codes each have a wire row in `retired_code_rows()`, and so do the retired message IDs `0004`, `0005`, `0006` and `8002` (#1551, #1627, #1628; `0006` is a frame with the payload the command had, `8002` an event ID under a command header: the decoder's `UNSUPPORTED_MESSAGE`, with no response frame because a message ID that decodes to no message has no safe request identity): rack codes 1 and 3 in every rack-addressed edit and in an automation target, track fields 6 and 8 in both flag forms, `TRANSPORT_SET` field 2 in both flag forms (#1635), each structural or declaration edit addressed at `console`, each of the thirteen value-op codes retired by #1561 (an unallocated opcode, refused at decode), and a `SetTrackConsole` in a transaction with no `SetConsole` before it that changes the console (`session.edit.value_op_retired`); an endpoint over `console_session_fixture()` answers each with its status and commits nothing. Auditable seed frames are checked in under [`fuzz/corpus`](../fuzz/corpus/README.md). | Evidence | Recorded result | Limitation | | --- | --- | --- | diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index b557eb507..5eff0c047 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -279,7 +279,7 @@ beside the effect diagnostics: `control.address.unknown` (no row), `control.auto (the row is rebuild-only, derived, a monitor row, a setting, or one of the two live rows that a ruling keeps out of automation: a send's `follows_mute`, a routing setting, and a strip's `matrix_mode`, a mode; #1632), `control.inactive` (the owner's shape does not use the row), `control.lane` (`left` or `right` on a shared row), `control.domain` (a segment whose unit is not the row's, or a value the row refuses), `control.automation.shape` and -`control.automation.order` (#1645, below). A console target on a submix +`control.automation.order` (#1645, below), and `control.automation.offset` (#1650, below). A console target on a submix addresses that submix's entry for the slot (#1202). A session does not have to state a parameter value for the row it automates: the row exists whether or not the session states it. @@ -306,6 +306,34 @@ The shape rule is one rule for every row: a toggle row (polarity, mute) or an en disabled value (a filter's `0`, "off") a `linear` or `exponential` segment has neither value equal to it, so "off" is entered and left only by a `step`. +**The hand-move offset (#1650).** Every automation entry carries a required `offset` record, key +4 after `segments` (`id` 1, `target` 2, `segments` 3), with two required finite `f32` keys, +`left_db` 1 and `right_db` 2: the dB offset a hand move adds to the curve of each lane. On the wire +it is field 4 of `Automation`, a required nested `AutomationOffset` message with two required `F32` +fields 1 and 2. A missing record or lane is `schema.missing_field` and a third key +`schema.unknown_field`, at their paths. The row rule (`controls::automation_diagnostics`) judges +each lane at `$.automation[id=].offset.left_db` or `.right_db`, after the segments: + +- Only `fader_db` (builtins control 5 of a strip) takes an offset (`takes_offset`). The input trim + and the send gain do not yet: the slices that render them (#1658, #1654) add them. +- A lane that takes no offset holds exactly `+0.0`, else `control.automation.offset`. That is a + lane the entry does not drive (`right_db` of a `left` entry, `left_db` of a `right` entry) and + every lane of an entry whose row takes no offset. `-0.0` is refused there too, so "no offset" + has one spelling. +- A lane a `fader_db` entry drives holds an offset in the fader's own domain, `[-144, 24]` dB (the + VCA offset domain), else `control.domain`. `-0.0` is a valid zero there. + +The offset of a lane belongs to the entry that drives the lane, so a lane-granular hand move on a +`both` entry needs no entry split. `RemoveAutomation` removes the offset with the entry; +`SetAutomationSegments` and `SetAutomationTarget` keep it, and the row rule judges it against the +new target. The offset is stored, checked and round-tripped (canonical JSON, wire, SDK). It is not +yet heard: render composes a fader event with the sum `S` of the reaching VCA offsets, and the +slice that adds the lane's own offset to `S` and routes a `SET_CONTROL fader_db` on an automated +lane to it (#1650b) lands in the same push as the fader automation render (#1646 to #1649). After +that slice the static fader value of an automated lane is not a control value: `SET_CONTROL` +writes the offset, and the static fallback changes only through a rebuild (remove the automation, +set the fader, upsert the automation, in one transaction) or a document replacement. + **The input filter order, on curves (#1645).** On each strip lane where HPF (3) or LPF (4) is automated (a `both` entry drives both lanes), each side is its entry under the hold rule, or the constant of its static value. The time axis is cut at every `start_sample` and `end_sample` of diff --git a/docs/session-v1.schema.json b/docs/session-v1.schema.json index e39bd1d55..6b7c0f59c 100644 --- a/docs/session-v1.schema.json +++ b/docs/session-v1.schema.json @@ -205,9 +205,13 @@ "type": "object", "additionalProperties": false, "required": ["shape", "start_sample", "end_sample", "start_value", "end_value", "unit"], "properties": { "shape": { "enum": ["step", "linear", "exponential"] }, "start_sample": { "$ref": "#/$defs/u64" }, "end_sample": { "$ref": "#/$defs/u64" }, "start_value": { "$ref": "#/$defs/f32" }, "end_value": { "$ref": "#/$defs/f32" }, "unit": { "$ref": "#/$defs/unit" } } }, + "automation_offset": { + "type": "object", "additionalProperties": false, "required": ["left_db", "right_db"], + "properties": { "left_db": { "$ref": "#/$defs/f32" }, "right_db": { "$ref": "#/$defs/f32" } } + }, "automation": { - "type": "object", "additionalProperties": false, "required": ["id", "target", "segments"], - "properties": { "id": { "$ref": "#/$defs/id" }, "target": { "$ref": "#/$defs/automation_target" }, "segments": { "type": "array", "items": { "$ref": "#/$defs/automation_segment" }, "minItems": 1 } } + "type": "object", "additionalProperties": false, "required": ["id", "target", "segments", "offset"], + "properties": { "id": { "$ref": "#/$defs/id" }, "target": { "$ref": "#/$defs/automation_target" }, "segments": { "type": "array", "items": { "$ref": "#/$defs/automation_segment" }, "minItems": 1 }, "offset": { "$ref": "#/$defs/automation_offset" } } } } } diff --git a/fixtures/session-canonical/v1/canonical-writer-corpus.json b/fixtures/session-canonical/v1/canonical-writer-corpus.json index 09aeaffa3..b96b8b330 100644 --- a/fixtures/session-canonical/v1/canonical-writer-corpus.json +++ b/fixtures/session-canonical/v1/canonical-writer-corpus.json @@ -5,11 +5,11 @@ "path": "fixtures/session/v1/canonical-minimal.json" }, { - "canonical": "{\n \"schema_version\": 1,\n \"session_id\": \"demo.session\",\n \"revision\": \"7\",\n \"sample_rate_hz\": 48000,\n \"quantum_frames\": 128,\n \"render_profile\": {\n \"id\": \"native\",\n \"mode\": \"single_thread\"\n },\n \"output_profile\": {\n \"id\": \"main\",\n \"channels\": 2,\n \"sample_format\": \"f32_planar\"\n },\n \"sources\": [\n {\n \"id\": \"voice\",\n \"content\": \"blake3:2a97516c354b68848cdbd8f54a226a0a55b21ed138e207ad6c5cbb9c00aa5aea\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"48000\"\n }\n ],\n \"console\": {\n \"pre_insert\": [\n {\n \"slot\": \"desk-eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.parametric-eq\"\n },\n \"quality\": \"high\",\n \"link_mode\": \"dual_mono\"\n }\n ],\n \"post_insert\": [\n {\n \"slot\": \"desk-limit\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.true-peak-limiter\"\n },\n \"quality\": \"normal\",\n \"link_mode\": \"maximum\"\n }\n ]\n },\n \"tracks\": [\n {\n \"id\": \"vocal\",\n \"source_id\": \"voice\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 20.0,\n \"lpf_hz\": 20000.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 20.0,\n \"lpf_hz\": 20000.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"desk-eq\",\n \"bypass\": true,\n \"params\": [\n {\n \"parameter_id\": 2,\n \"channel\": \"left\",\n \"unit\": \"db\",\n \"value\": -1.5\n }\n ]\n },\n {\n \"slot\": \"desk-limit\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"external\",\n \"identity\": {\n \"kind\": \"cid\",\n \"cid\": \"bafyopaque-v1-text\"\n },\n \"quality\": \"high\",\n \"bypass\": true,\n \"link_mode\": \"maximum\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"routed\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"vocal\",\n \"tap\": \"post_fader\"\n },\n \"port_id\": \"detector-in\"\n }\n },\n {\n \"id\": \"eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"parametric-eq\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 1,\n \"channel\": \"both\",\n \"unit\": \"db\",\n \"value\": 0.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"matrix\": {\n \"ll\": 1.25,\n \"lr\": -0.25,\n \"rl\": 0.5,\n \"rr\": 0.75,\n \"smoothing_samples\": 32\n }\n }\n ],\n \"submixes\": [\n {\n \"id\": \"mix\",\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": true,\n \"trim_db\": -3.5,\n \"hpf_hz\": 40.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 12\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 1.25,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 16000.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"desk-eq\",\n \"bypass\": false,\n \"params\": [\n {\n \"parameter_id\": 2,\n \"channel\": \"right\",\n \"unit\": \"hz\",\n \"value\": 180.0\n },\n {\n \"parameter_id\": 4,\n \"channel\": \"both\",\n \"unit\": \"db\",\n \"value\": 1.5\n }\n ]\n },\n {\n \"slot\": \"desk-limit\",\n \"bypass\": true,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"bus-eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.parametric-eq\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 2,\n \"channel\": \"both\",\n \"unit\": \"hz\",\n \"value\": 250.0\n },\n {\n \"parameter_id\": 3,\n \"channel\": \"right\",\n \"unit\": \"db\",\n \"value\": 2.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -6.0,\n \"right_db\": -4.5,\n \"left_mute\": false,\n \"right_mute\": true\n },\n \"pan\": {\n \"left\": -0.5,\n \"right\": 0.25,\n \"smoothing_samples\": 64\n }\n }\n ],\n \"vcas\": [\n {\n \"id\": \"group\",\n \"fader\": {\n \"left_db\": -144.0,\n \"right_db\": 2.25,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"members\": [\n \"vocal\"\n ]\n },\n {\n \"id\": \"master\",\n \"fader\": {\n \"left_db\": -3.5,\n \"right_db\": 2.25,\n \"left_mute\": false,\n \"right_mute\": true\n },\n \"members\": [\n \"group\",\n \"mix\",\n \"vocal\"\n ]\n },\n {\n \"id\": \"spare\",\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 2.25,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"members\": []\n }\n ],\n \"control_smoothing\": {\n \"kind\": \"explicit\",\n \"mute_ms\": -0.0,\n \"fader_ms\": 12.5,\n \"pan_ms\": 1000.0\n },\n \"outputs\": [\n {\n \"id\": \"main-out\"\n }\n ],\n \"routes\": [\n {\n \"id\": \"mix-to-main\",\n \"source\": {\n \"kind\": \"submix\",\n \"submix_id\": \"mix\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"to-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"vocal\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"submix_input\",\n \"submix_id\": \"mix\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n }\n ],\n \"automation\": [\n {\n \"id\": \"desk-gain\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"mix\",\n \"section\": \"console\",\n \"slot_id\": \"desk-eq\",\n \"control\": 4,\n \"lane\": \"right\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ]\n },\n {\n \"id\": \"eq-gain\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"vocal\",\n \"section\": \"inserts\",\n \"slot_id\": \"eq\",\n \"control\": 1,\n \"lane\": \"both\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ]\n },\n {\n \"id\": \"fader-ride\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"vocal\",\n \"section\": \"builtins\",\n \"control\": 5,\n \"lane\": \"left\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ]\n },\n {\n \"id\": \"route-gain\",\n \"target\": {\n \"owner\": \"route\",\n \"owner_id\": \"to-main\",\n \"section\": \"own\",\n \"control\": 1,\n \"lane\": \"both\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ]\n },\n {\n \"id\": \"vca-offset\",\n \"target\": {\n \"owner\": \"vca\",\n \"owner_id\": \"group\",\n \"section\": \"own\",\n \"control\": 1,\n \"lane\": \"both\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ]\n }\n ]\n}\n", + "canonical": "{\n \"schema_version\": 1,\n \"session_id\": \"demo.session\",\n \"revision\": \"7\",\n \"sample_rate_hz\": 48000,\n \"quantum_frames\": 128,\n \"render_profile\": {\n \"id\": \"native\",\n \"mode\": \"single_thread\"\n },\n \"output_profile\": {\n \"id\": \"main\",\n \"channels\": 2,\n \"sample_format\": \"f32_planar\"\n },\n \"sources\": [\n {\n \"id\": \"voice\",\n \"content\": \"blake3:2a97516c354b68848cdbd8f54a226a0a55b21ed138e207ad6c5cbb9c00aa5aea\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"48000\"\n }\n ],\n \"console\": {\n \"pre_insert\": [\n {\n \"slot\": \"desk-eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.parametric-eq\"\n },\n \"quality\": \"high\",\n \"link_mode\": \"dual_mono\"\n }\n ],\n \"post_insert\": [\n {\n \"slot\": \"desk-limit\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.true-peak-limiter\"\n },\n \"quality\": \"normal\",\n \"link_mode\": \"maximum\"\n }\n ]\n },\n \"tracks\": [\n {\n \"id\": \"vocal\",\n \"source_id\": \"voice\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 20.0,\n \"lpf_hz\": 20000.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 20.0,\n \"lpf_hz\": 20000.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"desk-eq\",\n \"bypass\": true,\n \"params\": [\n {\n \"parameter_id\": 2,\n \"channel\": \"left\",\n \"unit\": \"db\",\n \"value\": -1.5\n }\n ]\n },\n {\n \"slot\": \"desk-limit\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"external\",\n \"identity\": {\n \"kind\": \"cid\",\n \"cid\": \"bafyopaque-v1-text\"\n },\n \"quality\": \"high\",\n \"bypass\": true,\n \"link_mode\": \"maximum\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"routed\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"vocal\",\n \"tap\": \"post_fader\"\n },\n \"port_id\": \"detector-in\"\n }\n },\n {\n \"id\": \"eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"parametric-eq\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 1,\n \"channel\": \"both\",\n \"unit\": \"db\",\n \"value\": 0.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"matrix\": {\n \"ll\": 1.25,\n \"lr\": -0.25,\n \"rl\": 0.5,\n \"rr\": 0.75,\n \"smoothing_samples\": 32\n }\n }\n ],\n \"submixes\": [\n {\n \"id\": \"mix\",\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": true,\n \"trim_db\": -3.5,\n \"hpf_hz\": 40.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 12\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 1.25,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 16000.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"desk-eq\",\n \"bypass\": false,\n \"params\": [\n {\n \"parameter_id\": 2,\n \"channel\": \"right\",\n \"unit\": \"hz\",\n \"value\": 180.0\n },\n {\n \"parameter_id\": 4,\n \"channel\": \"both\",\n \"unit\": \"db\",\n \"value\": 1.5\n }\n ]\n },\n {\n \"slot\": \"desk-limit\",\n \"bypass\": true,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"bus-eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.parametric-eq\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 2,\n \"channel\": \"both\",\n \"unit\": \"hz\",\n \"value\": 250.0\n },\n {\n \"parameter_id\": 3,\n \"channel\": \"right\",\n \"unit\": \"db\",\n \"value\": 2.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -6.0,\n \"right_db\": -4.5,\n \"left_mute\": false,\n \"right_mute\": true\n },\n \"pan\": {\n \"left\": -0.5,\n \"right\": 0.25,\n \"smoothing_samples\": 64\n }\n }\n ],\n \"vcas\": [\n {\n \"id\": \"group\",\n \"fader\": {\n \"left_db\": -144.0,\n \"right_db\": 2.25,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"members\": [\n \"vocal\"\n ]\n },\n {\n \"id\": \"master\",\n \"fader\": {\n \"left_db\": -3.5,\n \"right_db\": 2.25,\n \"left_mute\": false,\n \"right_mute\": true\n },\n \"members\": [\n \"group\",\n \"mix\",\n \"vocal\"\n ]\n },\n {\n \"id\": \"spare\",\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 2.25,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"members\": []\n }\n ],\n \"control_smoothing\": {\n \"kind\": \"explicit\",\n \"mute_ms\": -0.0,\n \"fader_ms\": 12.5,\n \"pan_ms\": 1000.0\n },\n \"outputs\": [\n {\n \"id\": \"main-out\"\n }\n ],\n \"routes\": [\n {\n \"id\": \"mix-to-main\",\n \"source\": {\n \"kind\": \"submix\",\n \"submix_id\": \"mix\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"to-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"vocal\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"submix_input\",\n \"submix_id\": \"mix\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n }\n ],\n \"automation\": [\n {\n \"id\": \"desk-gain\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"mix\",\n \"section\": \"console\",\n \"slot_id\": \"desk-eq\",\n \"control\": 4,\n \"lane\": \"right\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ],\n \"offset\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0\n }\n },\n {\n \"id\": \"eq-gain\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"vocal\",\n \"section\": \"inserts\",\n \"slot_id\": \"eq\",\n \"control\": 1,\n \"lane\": \"both\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ],\n \"offset\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0\n }\n },\n {\n \"id\": \"fader-ride\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"vocal\",\n \"section\": \"builtins\",\n \"control\": 5,\n \"lane\": \"left\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ],\n \"offset\": {\n \"left_db\": 3.0,\n \"right_db\": 0.0\n }\n },\n {\n \"id\": \"route-gain\",\n \"target\": {\n \"owner\": \"route\",\n \"owner_id\": \"to-main\",\n \"section\": \"own\",\n \"control\": 1,\n \"lane\": \"both\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ],\n \"offset\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0\n }\n },\n {\n \"id\": \"vca-offset\",\n \"target\": {\n \"owner\": \"vca\",\n \"owner_id\": \"group\",\n \"section\": \"own\",\n \"control\": 1,\n \"lane\": \"both\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ],\n \"offset\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0\n }\n }\n ]\n}\n", "id": "canonical-full-surface" }, { - "canonical": "{\n \"schema_version\": 1,\n \"session_id\": \"demo.session\",\n \"revision\": \"7\",\n \"sample_rate_hz\": 48000,\n \"quantum_frames\": 128,\n \"render_profile\": {\n \"id\": \"native\",\n \"mode\": \"single_thread\"\n },\n \"output_profile\": {\n \"id\": \"main\",\n \"channels\": 2,\n \"sample_format\": \"f32_planar\"\n },\n \"sources\": [\n {\n \"id\": \"voice\",\n \"content\": \"blake3:2a97516c354b68848cdbd8f54a226a0a55b21ed138e207ad6c5cbb9c00aa5aea\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"48000\"\n }\n ],\n \"console\": {\n \"pre_insert\": [\n {\n \"slot\": \"desk-eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.parametric-eq\"\n },\n \"quality\": \"high\",\n \"link_mode\": \"dual_mono\"\n }\n ],\n \"post_insert\": [\n {\n \"slot\": \"desk-limit\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.true-peak-limiter\"\n },\n \"quality\": \"normal\",\n \"link_mode\": \"maximum\"\n }\n ]\n },\n \"tracks\": [\n {\n \"id\": \"vocal\",\n \"source_id\": \"voice\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 20.0,\n \"lpf_hz\": 20000.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 20.0,\n \"lpf_hz\": 20000.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"desk-eq\",\n \"bypass\": true,\n \"params\": [\n {\n \"parameter_id\": 2,\n \"channel\": \"left\",\n \"unit\": \"db\",\n \"value\": -1.5\n }\n ]\n },\n {\n \"slot\": \"desk-limit\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"external\",\n \"identity\": {\n \"kind\": \"cid\",\n \"cid\": \"bafyopaque-v1-text\"\n },\n \"quality\": \"high\",\n \"bypass\": true,\n \"link_mode\": \"maximum\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"routed\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"vocal\",\n \"tap\": \"post_fader\"\n },\n \"port_id\": \"detector-in\"\n }\n },\n {\n \"id\": \"eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"parametric-eq\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 1,\n \"channel\": \"both\",\n \"unit\": \"db\",\n \"value\": 0.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"pan\": {\n \"left\": 0.75,\n \"right\": -0.75,\n \"smoothing_samples\": 8\n }\n }\n ],\n \"submixes\": [\n {\n \"id\": \"mix\",\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": true,\n \"trim_db\": -3.5,\n \"hpf_hz\": 40.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 12\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 1.25,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 16000.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"desk-eq\",\n \"bypass\": false,\n \"params\": [\n {\n \"parameter_id\": 2,\n \"channel\": \"right\",\n \"unit\": \"hz\",\n \"value\": 180.0\n },\n {\n \"parameter_id\": 4,\n \"channel\": \"both\",\n \"unit\": \"db\",\n \"value\": 1.5\n }\n ]\n },\n {\n \"slot\": \"desk-limit\",\n \"bypass\": true,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"bus-eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.parametric-eq\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 2,\n \"channel\": \"both\",\n \"unit\": \"hz\",\n \"value\": 250.0\n },\n {\n \"parameter_id\": 3,\n \"channel\": \"right\",\n \"unit\": \"db\",\n \"value\": 2.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -6.0,\n \"right_db\": -4.5,\n \"left_mute\": false,\n \"right_mute\": true\n },\n \"matrix\": {\n \"ll\": 1.25,\n \"lr\": -0.25,\n \"rl\": 0.5,\n \"rr\": 0.75,\n \"smoothing_samples\": 32\n }\n }\n ],\n \"vcas\": [\n {\n \"id\": \"group\",\n \"fader\": {\n \"left_db\": -144.0,\n \"right_db\": 2.25,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"members\": [\n \"vocal\"\n ]\n },\n {\n \"id\": \"master\",\n \"fader\": {\n \"left_db\": -3.5,\n \"right_db\": 2.25,\n \"left_mute\": false,\n \"right_mute\": true\n },\n \"members\": [\n \"group\",\n \"mix\",\n \"vocal\"\n ]\n },\n {\n \"id\": \"spare\",\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 2.25,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"members\": []\n }\n ],\n \"control_smoothing\": {\n \"kind\": \"explicit\",\n \"mute_ms\": -0.0,\n \"fader_ms\": 12.5,\n \"pan_ms\": 1000.0\n },\n \"outputs\": [\n {\n \"id\": \"main-out\"\n }\n ],\n \"routes\": [\n {\n \"id\": \"mix-to-main\",\n \"source\": {\n \"kind\": \"submix\",\n \"submix_id\": \"mix\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"to-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"vocal\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"submix_input\",\n \"submix_id\": \"mix\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n }\n ],\n \"automation\": [\n {\n \"id\": \"desk-gain\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"mix\",\n \"section\": \"console\",\n \"slot_id\": \"desk-eq\",\n \"control\": 4,\n \"lane\": \"right\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ]\n },\n {\n \"id\": \"eq-gain\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"vocal\",\n \"section\": \"inserts\",\n \"slot_id\": \"eq\",\n \"control\": 1,\n \"lane\": \"both\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ]\n },\n {\n \"id\": \"fader-ride\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"vocal\",\n \"section\": \"builtins\",\n \"control\": 5,\n \"lane\": \"left\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ]\n },\n {\n \"id\": \"route-gain\",\n \"target\": {\n \"owner\": \"route\",\n \"owner_id\": \"to-main\",\n \"section\": \"own\",\n \"control\": 1,\n \"lane\": \"both\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ]\n },\n {\n \"id\": \"vca-offset\",\n \"target\": {\n \"owner\": \"vca\",\n \"owner_id\": \"group\",\n \"section\": \"own\",\n \"control\": 1,\n \"lane\": \"both\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ]\n }\n ]\n}\n", + "canonical": "{\n \"schema_version\": 1,\n \"session_id\": \"demo.session\",\n \"revision\": \"7\",\n \"sample_rate_hz\": 48000,\n \"quantum_frames\": 128,\n \"render_profile\": {\n \"id\": \"native\",\n \"mode\": \"single_thread\"\n },\n \"output_profile\": {\n \"id\": \"main\",\n \"channels\": 2,\n \"sample_format\": \"f32_planar\"\n },\n \"sources\": [\n {\n \"id\": \"voice\",\n \"content\": \"blake3:2a97516c354b68848cdbd8f54a226a0a55b21ed138e207ad6c5cbb9c00aa5aea\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"48000\"\n }\n ],\n \"console\": {\n \"pre_insert\": [\n {\n \"slot\": \"desk-eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.parametric-eq\"\n },\n \"quality\": \"high\",\n \"link_mode\": \"dual_mono\"\n }\n ],\n \"post_insert\": [\n {\n \"slot\": \"desk-limit\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.true-peak-limiter\"\n },\n \"quality\": \"normal\",\n \"link_mode\": \"maximum\"\n }\n ]\n },\n \"tracks\": [\n {\n \"id\": \"vocal\",\n \"source_id\": \"voice\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 20.0,\n \"lpf_hz\": 20000.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 20.0,\n \"lpf_hz\": 20000.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"desk-eq\",\n \"bypass\": true,\n \"params\": [\n {\n \"parameter_id\": 2,\n \"channel\": \"left\",\n \"unit\": \"db\",\n \"value\": -1.5\n }\n ]\n },\n {\n \"slot\": \"desk-limit\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"external\",\n \"identity\": {\n \"kind\": \"cid\",\n \"cid\": \"bafyopaque-v1-text\"\n },\n \"quality\": \"high\",\n \"bypass\": true,\n \"link_mode\": \"maximum\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"routed\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"vocal\",\n \"tap\": \"post_fader\"\n },\n \"port_id\": \"detector-in\"\n }\n },\n {\n \"id\": \"eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"parametric-eq\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 1,\n \"channel\": \"both\",\n \"unit\": \"db\",\n \"value\": 0.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"pan\": {\n \"left\": 0.75,\n \"right\": -0.75,\n \"smoothing_samples\": 8\n }\n }\n ],\n \"submixes\": [\n {\n \"id\": \"mix\",\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": true,\n \"trim_db\": -3.5,\n \"hpf_hz\": 40.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 12\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 1.25,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 16000.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"desk-eq\",\n \"bypass\": false,\n \"params\": [\n {\n \"parameter_id\": 2,\n \"channel\": \"right\",\n \"unit\": \"hz\",\n \"value\": 180.0\n },\n {\n \"parameter_id\": 4,\n \"channel\": \"both\",\n \"unit\": \"db\",\n \"value\": 1.5\n }\n ]\n },\n {\n \"slot\": \"desk-limit\",\n \"bypass\": true,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"bus-eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.parametric-eq\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 2,\n \"channel\": \"both\",\n \"unit\": \"hz\",\n \"value\": 250.0\n },\n {\n \"parameter_id\": 3,\n \"channel\": \"right\",\n \"unit\": \"db\",\n \"value\": 2.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -6.0,\n \"right_db\": -4.5,\n \"left_mute\": false,\n \"right_mute\": true\n },\n \"matrix\": {\n \"ll\": 1.25,\n \"lr\": -0.25,\n \"rl\": 0.5,\n \"rr\": 0.75,\n \"smoothing_samples\": 32\n }\n }\n ],\n \"vcas\": [\n {\n \"id\": \"group\",\n \"fader\": {\n \"left_db\": -144.0,\n \"right_db\": 2.25,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"members\": [\n \"vocal\"\n ]\n },\n {\n \"id\": \"master\",\n \"fader\": {\n \"left_db\": -3.5,\n \"right_db\": 2.25,\n \"left_mute\": false,\n \"right_mute\": true\n },\n \"members\": [\n \"group\",\n \"mix\",\n \"vocal\"\n ]\n },\n {\n \"id\": \"spare\",\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 2.25,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"members\": []\n }\n ],\n \"control_smoothing\": {\n \"kind\": \"explicit\",\n \"mute_ms\": -0.0,\n \"fader_ms\": 12.5,\n \"pan_ms\": 1000.0\n },\n \"outputs\": [\n {\n \"id\": \"main-out\"\n }\n ],\n \"routes\": [\n {\n \"id\": \"mix-to-main\",\n \"source\": {\n \"kind\": \"submix\",\n \"submix_id\": \"mix\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"to-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"vocal\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"submix_input\",\n \"submix_id\": \"mix\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n }\n ],\n \"automation\": [\n {\n \"id\": \"desk-gain\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"mix\",\n \"section\": \"console\",\n \"slot_id\": \"desk-eq\",\n \"control\": 4,\n \"lane\": \"right\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ],\n \"offset\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0\n }\n },\n {\n \"id\": \"eq-gain\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"vocal\",\n \"section\": \"inserts\",\n \"slot_id\": \"eq\",\n \"control\": 1,\n \"lane\": \"both\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ],\n \"offset\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0\n }\n },\n {\n \"id\": \"fader-ride\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"vocal\",\n \"section\": \"builtins\",\n \"control\": 5,\n \"lane\": \"left\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ],\n \"offset\": {\n \"left_db\": 3.0,\n \"right_db\": 0.0\n }\n },\n {\n \"id\": \"route-gain\",\n \"target\": {\n \"owner\": \"route\",\n \"owner_id\": \"to-main\",\n \"section\": \"own\",\n \"control\": 1,\n \"lane\": \"both\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ],\n \"offset\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0\n }\n },\n {\n \"id\": \"vca-offset\",\n \"target\": {\n \"owner\": \"vca\",\n \"owner_id\": \"group\",\n \"section\": \"own\",\n \"control\": 1,\n \"lane\": \"both\"\n },\n \"segments\": [\n {\n \"shape\": \"linear\",\n \"start_sample\": \"0\",\n \"end_sample\": \"480\",\n \"start_value\": 0.0,\n \"end_value\": -3.0,\n \"unit\": \"db\"\n }\n ],\n \"offset\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0\n }\n }\n ]\n}\n", "id": "canonical-exchanged-strips" } ], diff --git a/fixtures/session/v1/builtins-automation.json b/fixtures/session/v1/builtins-automation.json index 808451d3d..02dd09b6d 100644 --- a/fixtures/session/v1/builtins-automation.json +++ b/fixtures/session/v1/builtins-automation.json @@ -188,7 +188,11 @@ "end_value": -3.0, "unit": "db" } - ] + ], + "offset": { + "left_db": 0.0, + "right_db": 0.0 + } }, { "id": "pan-ride", @@ -208,7 +212,11 @@ "end_value": 0.5, "unit": "linear" } - ] + ], + "offset": { + "left_db": 0.0, + "right_db": 0.0 + } }, { "id": "polarity-flip", @@ -228,7 +236,11 @@ "end_value": 1.0, "unit": "linear" } - ] + ], + "offset": { + "left_db": 0.0, + "right_db": 0.0 + } }, { "id": "route-ride", @@ -248,7 +260,11 @@ "end_value": -6.0, "unit": "db" } - ] + ], + "offset": { + "left_db": 0.0, + "right_db": 0.0 + } }, { "id": "trim-ride-left", @@ -268,7 +284,11 @@ "end_value": -6.0, "unit": "db" } - ] + ], + "offset": { + "left_db": 0.0, + "right_db": 0.0 + } }, { "id": "vca-ride", @@ -288,7 +308,11 @@ "end_value": -3.0, "unit": "db" } - ] + ], + "offset": { + "left_db": 0.0, + "right_db": 0.0 + } } ] } diff --git a/fixtures/session/v1/canonical.json b/fixtures/session/v1/canonical.json index fdd9793e3..672122705 100644 --- a/fixtures/session/v1/canonical.json +++ b/fixtures/session/v1/canonical.json @@ -140,7 +140,11 @@ "end_value": -3.0, "unit": "db" } - ] + ], + "offset": { + "left_db": 0.0, + "right_db": 0.0 + } } ] } diff --git a/fuzz/corpus/complete-schema-manifest.md b/fuzz/corpus/complete-schema-manifest.md index 110712015..a4c20a01b 100644 --- a/fuzz/corpus/complete-schema-manifest.md +++ b/fuzz/corpus/complete-schema-manifest.md @@ -7,7 +7,7 @@ schemas. Optional/boundary values are represented by the transaction's nested fi transport-event origin, empty valid pages, and the typed backpressure variant. The canonical sequence is FNV-1a-64 over each stable frame label followed by its frame bytes: -`0c7196f86abe6d57`, pinned once as `COMPLETE_SCHEMA_HASH`. Native mutation, each typed fuzz +`5eb9ad2ed137f3be`, pinned once as `COMPLETE_SCHEMA_HASH`. Native mutation, each typed fuzz decoder, and scalar/simd128 Wasm execution all consume this same public fixture source. Issue #787 re-pinned this value after the transaction's two same-length source-identity spellings @@ -80,6 +80,9 @@ and never reallocated (the frame count stays 46); `command.transport_set` carrie `retired_code_rows()` gains the `TRANSPORT_SET` field 2 rows, one per flag form. Batch 13b carries #1678 and #1635 together and re-pins it once from `cc9a7bcc502c4936` (each alone gave `63ff2e60976e0e21` and `331654b324d076e8`). +Issue #1650 re-pinned it from `0c7196f86abe6d57`: the `Automation` message gains a required field 4, +the nested `AutomationOffset` (`left_db` 1, `right_db` 2), which the transaction's `UpsertAutomation` +carries (the frame count stays 46). The value stood at `88a8ee6a6d9e4acc` here until #274. It was correct until `b454b230`, and the two re-pins that followed (`b454b230`, then #241's `04d291dd`) did not reach this file or the diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index b35ff90f5..6b77bad50 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -152,7 +152,7 @@ fn maximum_document_with_dense_invalid_automation() -> DenseInvalidAutomationDoc const EMPTY_AUTOMATION: &str = "\"automation\": []"; const HEADER: &str = r#""automation": [{"id":"dense-invalid","target":{"owner":"strip","owner_id":"track","section":"builtins","control":5,"lane":"both"},"segments":["#; const INVALID_SEGMENT: &str = "{\"shape\":\"step\",\"start_sample\":\"0\",\"end_sample\":\"0\",\"start_value\":0.0,\"end_value\":0.0,\"unit\":\"db\"}"; - const FOOTER: &str = "]}]"; + const FOOTER: &str = r#"],"offset":{"left_db":0.0,"right_db":0.0}}]"#; let (before, after) = BASE .split_once(EMPTY_AUTOMATION) diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index d7a397d71..95fcc4319 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -2787,6 +2787,7 @@ fn a_mode_switch_under_a_stored_pan_entry_is_refused_alike_on_both_hosts() { end_value: 0.5, unit: session::ParameterUnit::Linear, }], + offset: session::AutomationOffset::default(), }); let mut browser = Rig::new(&model); let mut c_abi = CAbi::new(&model); diff --git a/scripts/check-protocol-wasm-parity.sh b/scripts/check-protocol-wasm-parity.sh index 3360955f2..b60830bc3 100755 --- a/scripts/check-protocol-wasm-parity.sh +++ b/scripts/check-protocol-wasm-parity.sh @@ -169,8 +169,8 @@ PY ) local -a files=("$PIN_FILE" "$PIN_FILE" "$GUEST_FILE") local -a searches=( - 'COMPLETE_SCHEMA_HASH: u64 = 0x0c71_96f8_6abe_6d57;' - 'COMPLETE_SCHEMA_HASH: u64 = 0x0c71_96f8_6abe_6d57;' + 'COMPLETE_SCHEMA_HASH: u64 = 0x5eb9_ad2e_d137_f3be;' + 'COMPLETE_SCHEMA_HASH: u64 = 0x5eb9_ad2e_d137_f3be;' 'assert_eq!(corpus.len(), 46);' ) local -a replacements=( diff --git a/sdk/assets/miso-engine-v1-session-keys.json b/sdk/assets/miso-engine-v1-session-keys.json index b57cc5c7c..c9a13a155 100644 --- a/sdk/assets/miso-engine-v1-session-keys.json +++ b/sdk/assets/miso-engine-v1-session-keys.json @@ -339,7 +339,16 @@ "keys": [ ["id", "id"], ["target", "record"], - ["segments", "array"] + ["segments", "array"], + ["offset", "record"] + ] + }, + { + "module": "automation_offset", + "arm": null, + "keys": [ + ["left_db", "float"], + ["right_db", "float"] ] }, { @@ -484,6 +493,12 @@ { "module": "matrix_or_pan", "arm": "matrix" } ] }, + { + "key": "offset", + "records": [ + { "module": "automation_offset", "arm": null } + ] + }, { "key": "output_profile", "records": [ diff --git a/sdk/src/cli/session-request.ts b/sdk/src/cli/session-request.ts index 6f738025b..bd159a22c 100644 --- a/sdk/src/cli/session-request.ts +++ b/sdk/src/cli/session-request.ts @@ -470,7 +470,7 @@ function requestU64(value: unknown, path: string): bigint { function automation(value: unknown, path: string): AutomationSpec { const raw = record(value, path); - keys(raw, ["id", "target", "segments"], ["id", "target", "segments"], path); + keys(raw, ["id", "target", "segments", "offset"], ["id", "target", "segments"], path); const target = record(raw.target, `${path}.target`); keys(target, ["owner", "section", "control", "lane"], ["owner", "section", "control", "lane"], `${path}.target`); // The control address `edit.set` takes (#1566): the builder checks the owner, the slot and the row. @@ -490,6 +490,16 @@ function automation(value: unknown, path: string): AutomationSpec { endValue: number(segment.endValue, `${where}.endValue`), }; }); + // The stored hand-move offset (#1650): optional in a request, `0` per lane when omitted. + let offset: AutomationSpec["offset"]; + if (raw.offset !== undefined) { + const supplied = record(raw.offset, `${path}.offset`); + keys(supplied, ["leftDb", "rightDb"], [], `${path}.offset`); + offset = { + ...(supplied.leftDb === undefined ? {} : { leftDb: number(supplied.leftDb, `${path}.offset.leftDb`) }), + ...(supplied.rightDb === undefined ? {} : { rightDb: number(supplied.rightDb, `${path}.offset.rightDb`) }), + }; + } return { id: string(raw.id, `${path}.id`), // The kinds are checked by the builder, which refuses what the engine's grammar refuses. @@ -509,6 +519,7 @@ function automation(value: unknown, path: string): AutomationSpec { lane: string(target.lane, `${path}.target.lane`), } as unknown as AutomationSpec["target"], segments, + ...(offset === undefined ? {} : { offset }), }; } diff --git a/sdk/src/core/session.ts b/sdk/src/core/session.ts index eea230b55..f0dc66053 100644 --- a/sdk/src/core/session.ts +++ b/sdk/src/core/session.ts @@ -925,6 +925,7 @@ export class SessionBuilder { automation: [...this.#state.automation, freeze({ ...spec, segments: spec.segments.map((segment) => ({ ...segment })), + ...(spec.offset === undefined ? {} : { offset: { ...spec.offset } }), })], }); } @@ -1002,6 +1003,7 @@ export class SessionBuilder { if (spec.segments.length === 0) { fail(`${path}.segments`, "automation must declare at least one segment"); } + normalizeAutomationOffset(spec.offset, `${path}.offset`); } #next(update: Partial): SessionBuilder { @@ -1605,6 +1607,18 @@ function normalizeAutomation(spec: AutomationSpec, state: BuilderState): ModelRe lane: resolved.lane, }), segments, + offset: normalizeAutomationOffset(spec.offset, `${path}.offset`), + }); +} + +/** + * The stored hand-move offset (#1650): two `f32` dB lanes, `0` when omitted. Which row and lane + * may hold a non-zero offset, and its domain, are the engine's rule (`validate()`). + */ +function normalizeAutomationOffset(raw: AutomationSpec["offset"], path: string): ModelRecord { + return freeze({ + left_db: raw?.leftDb === undefined ? 0 : f32(raw.leftDb, `${path}.leftDb`), + right_db: raw?.rightDb === undefined ? 0 : f32(raw.rightDb, `${path}.rightDb`), }); } diff --git a/sdk/src/core/types.ts b/sdk/src/core/types.ts index 7c68c76cd..1f72f67e8 100644 --- a/sdk/src/core/types.ts +++ b/sdk/src/core/types.ts @@ -336,8 +336,22 @@ export interface AutomationSegment { */ export type AutomationTarget = ControlAddress | ControlWireAddress; +/** + * The stored hand-move offset of an automation entry, in dB per lane (#1650). A hand move on an + * automated fader is heard on top of the ride as this offset. Only a `fader_db` entry may hold a + * non-zero offset, and only on a lane it drives; every other lane holds `0`. The engine checks + * that rule and the fader's `[-144, 24]` dB domain, and its refusal (`control.automation.offset`, + * `control.domain`) comes back from `validate()` and preparation. + */ +export interface AutomationOffset { + readonly leftDb?: number; + readonly rightDb?: number; +} + export interface AutomationSpec { readonly id: string; readonly target: AutomationTarget; readonly segments: readonly AutomationSegment[]; + /** The stored hand-move offset; omitted, both lanes are `0` (no offset). */ + readonly offset?: AutomationOffset; } diff --git a/sdk/src/generated/session-keys.ts b/sdk/src/generated/session-keys.ts index e35ecbf04..ad3bdb000 100644 --- a/sdk/src/generated/session-keys.ts +++ b/sdk/src/generated/session-keys.ts @@ -767,6 +767,24 @@ export const SESSION_KEYS = deepFreeze( [ "segments", "array" + ], + [ + "offset", + "record" + ] + ] + }, + { + "module": "automation_offset", + "arm": null, + "keys": [ + [ + "left_db", + "float" + ], + [ + "right_db", + "float" ] ] }, @@ -1047,6 +1065,15 @@ export const SESSION_KEYS = deepFreeze( } ] }, + { + "key": "offset", + "records": [ + { + "module": "automation_offset", + "arm": null + } + ] + }, { "key": "output_profile", "records": [ diff --git a/sdk/test/builder-evals.mjs b/sdk/test/builder-evals.mjs index 963541d1b..928d90769 100644 --- a/sdk/test/builder-evals.mjs +++ b/sdk/test/builder-evals.mjs @@ -1575,3 +1575,104 @@ describe("issue 1364 -- the builder authors controlSmoothing", () => { ); }); }); + +describe("issue #1650 -- the builder authors an automation entry's offset", () => { + const base = () => session({ id: "offset.eval", sampleRateHz: 48_000, revision: 1 }) + .source("stem", { channels: 2, bitDepth: 24, frames: 480, content: CONTENT_A }) + .track("t", { source: "stem" }) + .output("out") + .route({ + id: "main", + source: { kind: "track", trackId: "t", tap: "post_pan" }, + destination: { kind: "output_input", outputId: "out" }, + }); + const fader = (lane) => ({ + owner: { kind: "strip", id: "t" }, + section: { kind: "builtins" }, + control: "fader_db", + lane, + }); + const ride = (id, lane, offset) => ({ + id, + target: fader(lane), + segments: [{ shape: "linear", startSample: 0n, endSample: 480n, startValue: -6, endValue: 0 }], + ...(offset === undefined ? {} : { offset }), + }); + + test("the offset is written after the segments, 0 per lane when omitted", () => { + // Red if the builder leaves the required key out, writes it in another place, or loses a + // lane or the sign of a -0. + const omitted = base().automation(ride("ride", "left")).toJSON(); + assert.deepEqual(omitted.automation[0].offset, { left_db: 0, right_db: 0 }); + assert.deepEqual(Object.keys(omitted.automation[0]), ["id", "target", "segments", "offset"]); + const json = base().automation(ride("ride", "both", { leftDb: 3, rightDb: -0 })).toJson(); + assert.match(json, /"segments": \[[\s\S]*\],\n {6}"offset": \{\n {8}"left_db": 3\.0,\n {8}"right_db": -0\.0\n {6}\}\n {4}\}/); + assert.throws( + () => base().automation(ride("ride", "left", { leftDb: Number.NaN })), + /automation\("ride"\)\.offset\.leftDb: expected a finite number/, + ); + }); + + test("a strict request with an offset builds the session the builder builds", () => { + // Red if the request decoder drops the offset or lets a misspelled key through. + const request = (offset) => ({ + schemaVersion: 1, + session: { id: "offset.eval", sampleRateHz: 48_000, revision: 1 }, + sources: [{ id: "stem", spec: { channels: 2, bitDepth: 24, frames: 480, content: CONTENT_A } }], + tracks: [{ id: "t", spec: { source: "stem" } }], + outputs: ["out"], + routes: [{ + id: "main", + source: { kind: "track", trackId: "t", tap: "post_pan" }, + destination: { kind: "output_input", outputId: "out" }, + }], + automation: [{ + id: "ride", + target: { owner: { kind: "strip", id: "t" }, section: { kind: "builtins" }, control: "fader_db", lane: "left" }, + segments: [{ shape: "linear", startSample: 0, endSample: 480, startValue: -6, endValue: 0 }], + ...(offset === undefined ? {} : { offset }), + }], + }); + assert.equal( + sessionBuilderFromRequest(request({ leftDb: 3 })).toJson(), + base().automation(ride("ride", "left", { leftDb: 3 })).toJson(), + ); + assert.equal( + sessionBuilderFromRequest(request(undefined)).toJson(), + base().automation(ride("ride", "left")).toJson(), + ); + assert.throws( + () => sessionBuilderFromRequest(request({ leftDB: 3 })), + (error) => error.message === "$.automation[0].offset.leftDB: unknown key", + ); + }); + + test("which row and lane take an offset is the engine's rule", async () => { + // #1650 D2: only a lane a `fader_db` entry drives takes an offset, inside [-144, 24] dB. + // Red if the builder starts judging the row (the engine's code is never observed) or the + // engine accepts an offset on a lane no one reads. + const outcome = async (builder) => { + const result = await validate(builder, { asset }); + return result.ok ? "ok" : [result.diagnostics[0]?.code, result.diagnostics[0]?.path]; + }; + assert.equal(await outcome(base().automation(ride("ride", "left", { leftDb: 3 }))), "ok"); + assert.deepEqual( + await outcome(base().automation(ride("ride", "left", { rightDb: 3 }))), + ["control.automation.offset", "$.automation[id=ride].offset.right_db"], + ); + assert.deepEqual( + await outcome(base().automation(ride("ride", "both", { leftDb: 30 }))), + ["control.domain", "$.automation[id=ride].offset.left_db"], + ); + const trim = { + id: "trim", + target: { ...fader("left"), control: "trim_db" }, + segments: [{ shape: "linear", startSample: 0n, endSample: 480n, startValue: -6, endValue: 0 }], + offset: { leftDb: 1 }, + }; + assert.deepEqual( + await outcome(base().automation(trim)), + ["control.automation.offset", "$.automation[id=trim].offset.left_db"], + ); + }); +}); diff --git a/tools/session-validator/tests/validate.rs b/tools/session-validator/tests/validate.rs index c153bd089..a33d6e767 100644 --- a/tools/session-validator/tests/validate.rs +++ b/tools/session-validator/tests/validate.rs @@ -605,7 +605,8 @@ fn an_automation_on_a_prepared_effect_parameter_fails_effect_preparation() { "start_value": 1.0, "end_value": 1.0, "unit": unit - }] + }], + "offset": { "left_db": 0.0, "right_db": 0.0 } }]); let report = validate_session_document(&serde_json::to_string(&value).unwrap()); if !expected { From 37150cef841d7637c6540cf03eb4e297671d9630 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 18:04:06 +0000 Subject: [PATCH 05/35] Prepare stored fader automation and render it flat (#1646b) Every builtins preparation form compiles one cell program per automated fader lane (tracks and submixes) with the fader row's cell rule from controls, seeds each cell's event state, holds the lane's VCA sum S (controls::effective) in a three-word offsets cell, and sets the prepared value vca_compose_db(first value, S) inside CanonicalFpEnv. The scalar oracle installs the same program (R17-4). Bytes are charged as measured into builtin_bank_bytes and the graph plan rows. A flat curve renders the static fader's bits on the C ABI and the browser. audit capi pcm_digest unchanged (bffc21e857b5e134). Fields whose readers are #1647, #1648 and #1649 (same push) carry expect(dead_code) until those land. Co-Authored-By: Claude Opus 5.5 --- .../1646-prepare-stored-fader-automation.md | 96 ++++ Cargo.lock | 2 + crates/automation/src/events.rs | 86 ++++ crates/builtins-compiler/Cargo.toml | 2 + crates/builtins-compiler/src/lib.rs | 483 +++++++++++++++++- crates/capi/src/runtime/tests.rs | 190 +++++++ crates/controls/src/automation.rs | 98 +++- crates/controls/src/builtin_rows.rs | 2 +- crates/controls/src/effective.rs | 9 +- crates/controls/src/lib.rs | 5 +- crates/controls/tests/composition.rs | 9 + crates/controls/tests/effective.rs | 1 + crates/host-core/Cargo.toml | 2 + .../tests/stored_fader_automation.rs | 278 ++++++++++ hosts/host-web/src/tests.rs | 189 +++++++ 15 files changed, 1423 insertions(+), 29 deletions(-) create mode 100644 crates/host-core/tests/stored_fader_automation.rs diff --git a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md index 48ae500ea..d2bf12932 100644 --- a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md @@ -481,3 +481,99 @@ D6 only. Shipped: processor method. No downcast in host-core. This slice needs no `a(n)` (departure 1). - **Q2 (R17-4): B.** The program is installed on the test-only scalar oracle too, through the one shared event driver. The scalar backend is not refused. + +## Decision record: #1646b (batch 17) + +The preparation (D1-D5). Shipped: + +- **Thick core (`controls`).** `EffectiveStrip::offsets_sum` is `S` per lane + (`vca_offsets_sum` of the reach, `+0.0` with none), written by `effective_strip` beside + `fader_db`; #1649 departure 3 only reads it. `crates/controls/src/automation.rs` gains + `CellRule` and `FADER_CELL_RULE` (grid 64, grid ramp 64, `End`, `JumpLength::Block`, restartable, + `SeekMode::Exact`), `stored_segments` (the one segment conversion, now also read by the order + rule), `automation_row_renders` (today the fader row; #1648's classifier reuses it) and + `fader_automation(model, strip) -> [Option<&Automation>; 2]`. +- **Seed (`automation`).** `CellState::seed_target(value)` sets the target as a completed retarget + (clears `held`, keeps the cursor and the ramp); #1648 reuses it. Test + `events::tests::a_seeded_target_suppresses_the_first_retarget_to_the_same_bits`. +- **Preparation (`builtins-compiler`).** The shared private body of every preparation form calls + `prepare_fader_automation` after the preflight and before phase two: one cell per automated + fader lane of every strip (tracks and submixes; `both` gives two), in strip then channel order, + built once into one `Arc`. Each lane's prepared value is its first `start_value` (D2, + the hold rule) composed as `vca_compose_db(value, S)`; the composition and its + `checked_fader_gain` run inside `CanonicalFpEnv`. That dB is the lane's prepared fader parameter + (departure 10), so the existing `checked_fader_gain` path bakes the static bits. +- **Install.** At lowering each fader bank with an automated member gets one + `Option` slot per bank lane (`FaderBankProcessor::automation`, empty and + unallocated otherwise); a slot holds the program handle and per channel an `AutomatedFaderCell`: + cell index, `CellState` seeded with the prepared value, its own offsets cell (`Arc` of a + `LatestCell<3>` and its dirty word, created unwritten) and the held `S` and ramp word (the + session's fader length, `LiveRampRow::VcaOffset`). The test-only scalar oracle (R17-4) holds the + same `LaneFaderAutomation`, boxed, on both its fader owners (`FaderProcessor`, + `LiveControlFaderProcessor`); the scalar backend is not refused. The fused fader-matrix bank + holds the fader bank, so it carries the slots. +- **Bytes (D4).** `strip_bank_resource` (the one sum the preflight `graph_builtin_bank_resource` + and the lowering both take) adds `fader_automation_bank_resource`: the program once + (`size_of::>()` plus `ProgramBuilder::bytes`), `width` slots per bank + with an automated member and one offsets cell (`size_of::>()`) + per automated channel, as builtin bank payload, so they reach `builtin_bank_bytes`, + `graph_incremental_plan_bytes`, `graph_session_plus_plan_bytes`, the host's cap and the C ABI's + replacement peak. The oracle's are charged in `graph_scalar_owner_resource`. The sizes are + `builtins_compiler::fader_automation_sizes()`, measured, never literals. + +**Measured (x86-64, AVX2, eight lanes):** program overhead 64 bytes, offsets cell 120, lane slot +120 (`Option`: program handle and two channels with their 32-byte +`CellState`). One left-only lane with one segment: program report 68, charge +`68 + 64 + 8 × 120 + 120 = 1,212` bytes (`builtin_bank_bytes` 8,472 -> 9,684 on the bank +fixture). Each further automated channel in the same bank adds 120 plus its cells' program bytes. +A session without fader automation carries the empty slot array: `size_of::()` +grows by 16 bytes per fader bank, a fixed structural change inside every ceiling (no re-pin). + +**Departures.** + +1. **The program is built in the builtins preparation, not in host-core** (departure 6 said + host-core builds it and passes it). The shared body of all five public forms builds it from + the session and the `Effective` it already takes, so every form installs it with no new + argument, no caller can omit it (departure 7's aim), the oracle and the graph compiler's + preflight read the same plan, and no public signature changes. The row rule, the segment + conversion and `S` stay written once in `controls`; host-core has no edit. +2. **`host.automation.fader` is raised by that body**, so it reaches a host as a builtin + rejection, at `$.automation[id=]`. It also covers a builder refusal: a session may hold a + segment end at or past `2^53` samples, which the session validator accepts and + `ProgramBuilder` refuses (`BuildError::Segment`), so that refusal is reachable, not only the + unreachable conversion of D2. +3. **No offsets-cell writer here.** #1649 departure 4 adds the writer to `FaderCells`; the cell is + created unwritten and its holder keeps the prepared words. +4. **Parts with no reader until the same push** carry `#[expect(dead_code, reason = ...)]`, which + turns into a warning (and so must go) as soon as the successor reads them: the program, the + seeded `CellState`, the held `S` and ramp word (render: #1647), the offsets cell (write and + dirty read: #1649), the slot carry in `adopt_lane` (#1648). Today the prepared value alone + renders: a flat curve exactly, a moving curve held at its first value. +5. **The oracle's plain `FaderProcessor`** (a strip with no live-control request) holds the + automation but wraps the unramped fader, which takes no timed operation; #1647 binds an + automated oracle strip on the ramped fader. +6. `a(n)` is #1647's (R17-2); nothing here reads it. + +**Gates.** + +- Gate 1: `a_flat_fader_automation_renders_the_static_fader_bits_on_the_c_abi` + (`crates/capi/src/runtime/tests.rs`) and + `a_flat_fader_automation_renders_the_static_fader_bits_in_the_browser` + (`hosts/host-web/src/tests.rs`, live-controlled boot): no VCA, one `+3` dB VCA, the crafted three + VCAs (asserting the two orders give dB bits `0x40ad_8ad3` and `0x40ad_8ad2` and different gain + bits) and `-0.0` dB on a lane neither of two VCAs reaches, 64 blocks each, and each automated + session differs from itself without the automation. +- Gate 2: `a_fader_automation_is_charged_to_the_graph_plan_rows_and_the_cap` + (`crates/host-core/tests/stored_fader_automation.rs`): the three rows differ by exactly the + charge above; the admitted total prepares and one byte below refuses with + `host.graph.resource.limit`. +- Gate 3: `a_left_only_fader_automation_keeps_a_mono_tracks_collapse` (same file): the collapse + counters equal the session without automation, and the output equals the twin's dual render. +- Gate 4: `./target/release/audit capi` `pcm_digest` `bffc21e857b5e134` (unchanged), 0 + violations, 0 allocations; every existing render test of `-p session`, `-p controls`, + `-p automation`, `-p host-core`, `-p builtins-compiler`, `-p capi`, `-p host-web` passes with + no digest moved. One layout test moved mechanically: + `scalar_owner_resource_is_independent_two_boxes_plus_two_pointer_outer` now counts the oracle + fader owner's new `automation` field. Gate 6 landed with #1646a. `S` in + `vca_reach_counts_a_diamond_once_and_includes_nested_parents_in_id_order` + (`crates/controls/tests/composition.rs`) now also pins `offsets_sum`, `+0.0` by bits. diff --git a/Cargo.lock b/Cargo.lock index cf08d775f..9fef48ecc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,6 +208,7 @@ dependencies = [ name = "builtins-compiler" version = "0.1.0" dependencies = [ + "automation", "builtins", "controls", "effect-contract", @@ -955,6 +956,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" name = "host-core" version = "0.1.0" dependencies = [ + "automation", "bench-support", "builtins", "builtins-compiler", diff --git a/crates/automation/src/events.rs b/crates/automation/src/events.rs index 38de26e1c..d5b882a3f 100644 --- a/crates/automation/src/events.rs +++ b/crates/automation/src/events.rs @@ -145,6 +145,19 @@ impl CellState { self.ramp_done = completion; } + /// Sets the current target to `value`, as if a retarget to it had completed: the next grid or + /// jump event to the same bits emits nothing (change only). It clears an outstanding held + /// event and keeps the cursor, the ramp in flight and a deferred jump. + /// + /// A stage that starts a lane at a value it already holds seeds the cell with it: preparation + /// with the lane's prepared curve value (#1646 D2), a plan swap with the carried stage target + /// (#1648 D2). Control side; it allocates nothing. + pub const fn seed_target(&mut self, value: f32) { + self.target = value.to_bits(); + self.has_target = true; + self.held = false; + } + /// The bits of the current target, if the cell has one. #[must_use] pub const fn target(&self) -> Option { @@ -496,3 +509,76 @@ impl Iterator for CellEvents<'_, '_> { } } // REALTIME_POLICY_END + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use crate::{ + CellDescription, CellEvents, CellState, Completion, EventKind, JumpLength, NodeSpan, + ProgramBuilder, SeekMode, Segment, Shape, + }; + + fn flat(value: f32) -> crate::Program { + let segments = [Segment { + start: 0, + end: 4_096, + start_value: value, + end_value: value, + shape: Shape::Linear, + }]; + ProgramBuilder::build(&[CellDescription { + segments: &segments, + grid_ramp: 64, + grid: 64, + jump: JumpLength::Block, + completion: Completion::End, + no_restart: false, + }]) + .expect("valid cell") + } + + fn first_block(program: &crate::Program, state: &mut CellState) -> Vec<(u32, EventKind)> { + let span = NodeSpan::new(0, None, 256).expect("span"); + CellEvents::new( + program.cell(0).expect("cell"), + state, + span, + SeekMode::Exact, + 480, + ) + .map(|event| (event.offset, event.kind)) + .collect() + } + + /// #1646 D2: a cell seeded with its prepared value emits nothing on a flat curve at that + /// value, while a fresh cell retargets at its first grid point; a seed at another value still + /// retargets. + /// + /// Red if `seed_target` does not set the target: the first grid event of every prepared lane + /// would then retarget the lane to the value it already holds. + #[test] + fn a_seeded_target_suppresses_the_first_retarget_to_the_same_bits() { + let program = flat(-7.25); + assert_eq!( + first_block(&program, &mut CellState::new()), + [(0, EventKind::Grid)] + ); + + let mut seeded = CellState::new(); + seeded.seed_target(-7.25); + assert_eq!( + seeded.target().map(f32::to_bits), + Some((-7.25_f32).to_bits()) + ); + assert_eq!(first_block(&program, &mut seeded), []); + + let mut other = CellState::new(); + other.seed_target(-7.0); + assert_eq!(first_block(&program, &mut other), [(0, EventKind::Grid)]); + assert_eq!( + other.target().map(f32::to_bits), + Some((-7.25_f32).to_bits()) + ); + } +} diff --git a/crates/builtins-compiler/Cargo.toml b/crates/builtins-compiler/Cargo.toml index 9f9d1b92a..d59f66101 100644 --- a/crates/builtins-compiler/Cargo.toml +++ b/crates/builtins-compiler/Cargo.toml @@ -10,6 +10,8 @@ publish.workspace = true name = "builtins_compiler" [dependencies] +# #1646: stored fader automation is compiled into a cell program at preparation. +automation.workspace = true controls.workspace = true lane.workspace = true engine.workspace = true diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 1784af3a6..a2bcff09c 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -610,6 +610,271 @@ fn matrix_lane(superseded: &LiveValuesSuperseded) -> (MatrixCells, MatrixReader) ) } +/// Words of an automated fader lane's offsets cell (#1646 D1, written by #1649): the lane's offset +/// sum `S` as the low and high words of its `f64` bits, then the ramp word (the jump length a VCA +/// edit asks for). +const OFFSETS_CELL_WORDS: usize = 3; + +/// What one automated fader channel's offsets cell shares between its writer (control, #1649) and +/// its reader (render, #1647): one #1312 latest-target cell and its dirty word. +/// +/// The cell is created unwritten; its reader holds the prepared words until its first applied +/// write (#1646 departure 5, as #1639's jump-length cell does). +#[expect( + dead_code, + reason = "#1649 writes the cell and #1647 reads it in render, in the same push (batch R1)" +)] +struct OffsetsLane { + cell: LatestCell, + dirty: DirtyWord, +} + +/// One automated fader channel as a fader owner holds it (#1646 D1): its cell in the plan's +/// program, its event state and its offsets cell with the words render holds until the first +/// write. +/// +/// The event state lives here, beside the lane's ramp state, so the stage's carry moves it with +/// the lane (#1648 extends [`FaderBankProcessor::adopt_lane`]). +#[expect( + dead_code, + reason = "#1647 drives the events and composes the gain from these in render, in the same push \ + (batch R1)" +)] +struct AutomatedFaderCell { + /// The cell's index in the plan's [`automation::Program`]. + cell: u32, + /// Seeded with the prepared curve value, so the first grid event on a flat curve emits nothing. + state: automation::CellState, + offsets: Arc, + /// `S`, held until the offsets cell's first applied write. + offsets_sum: f64, + /// The ramp word, held until the offsets cell's first applied write: the session's fader + /// length (`LiveRampRow::VcaOffset`, #1054). + offsets_ramp: u32, +} + +/// One strip lane's stored fader automation (#1646 D1): the plan's program and the lane's +/// automated channels. The one event driver both fader owners hold, the fader bank per lane and +/// the test-only scalar oracle per strip (R17-4), so neither has a second copy of the rule. +#[expect( + dead_code, + reason = "#1647 drives the events from the program in render, in the same push (batch R1)" +)] +struct LaneFaderAutomation { + program: Arc, + /// `[left, right]`; `None` for a channel no entry drives. + channels: [Option; 2], +} + +impl LaneFaderAutomation { + /// The lane's automation, from its prepared cells. Control side: it allocates one offsets + /// cell per automated channel. + fn new(program: &Arc, cells: &[Option; 2]) -> Self { + Self { + program: Arc::clone(program), + channels: cells.map(|cell| { + cell.map(|cell| { + let mut state = automation::CellState::new(); + state.seed_target(cell.value); + AutomatedFaderCell { + cell: cell.cell, + state, + offsets: Arc::new(OffsetsLane { + cell: LatestCell::new(), + dirty: DirtyWord::new(), + }), + offsets_sum: cell.offsets_sum, + offsets_ramp: cell.offsets_ramp, + } + }) + }), + } + } +} + +/// One automated fader channel as preparation computed it (#1646 D1, D2): plain data, no +/// allocation. +#[derive(Clone, Copy, Debug)] +struct PreparedFaderCell { + /// The cell's index in the program. + cell: u32, + /// The prepared curve value: the entry's first `start_value`, the curve at node time `-a(n)` + /// for every `a(n)` (D2, the hold rule). + value: f32, + /// `S` (`controls::EffectiveStrip::offsets_sum`). + offsets_sum: f64, + /// The offsets cell's ramp word. + offsets_ramp: u32, +} + +/// The stored fader automation of one preparation (#1646): the program every automated lane +/// reads, its byte report, and each automated strip's channels. +struct FaderAutomationPlan { + program: Arc, + /// [`automation::ProgramBuilder::bytes`] of the program. + program_bytes: u64, + /// Each strip that has an automated fader lane, by track ID. + strips: BTreeMap, [Option; 2]>, +} + +impl FaderAutomationPlan { + /// The prepared cells of strip `track`, if any channel is automated. + fn strip(&self, track: &str) -> Option<&[Option; 2]> { + self.strips.get(track) + } + + /// The prepared dB of each lane of strip `track`: `vca_compose_db(value, S)` on an automated + /// lane (D2), `effective` elsewhere. + fn fader_db(&self, track: &str, effective: [f32; 2]) -> [f32; 2] { + let Some(cells) = self.strip(track) else { + return effective; + }; + [0, 1].map(|lane| { + cells[lane].map_or(effective[lane], |cell| { + session::vca_compose_db(cell.value, cell.offsets_sum) + }) + }) + } + + /// The bytes the program retains: its shared allocation and the builder's report. + fn retained_program_bytes(&self) -> Option { + fader_automation_sizes() + .program_overhead_bytes + .checked_add(self.program_bytes) + } + + /// The automated channels among `members`. + fn automated_channels<'a>(&self, members: impl Iterator) -> Option { + members + .filter_map(|track| self.strip(track)) + .map(|cells| cells.iter().filter(|cell| cell.is_some()).count()) + .try_fold(0_u64, |total, count| { + total.checked_add(u64::try_from(count).ok()?) + }) + } +} + +/// The fixed retained sizes of stored fader automation (#1646 D4), measured on the build that +/// runs, never written as literals. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FaderAutomationSizes { + /// The shared allocation that holds a plan's program, beyond the builder's byte report + /// ([`automation::ProgramBuilder::bytes`]), which is charged with it once per plan. + pub program_overhead_bytes: u64, + /// One automated channel's offsets cell: its shared allocation (the three-word + /// `LatestCell<3>` and its dirty word). + pub offsets_cell_bytes: u64, + /// One lane's automation slot on a fader owner (the event state, `S`, the ramp word and the + /// program handle of both channels). A fader bank with an automated member holds one per bank + /// lane. + pub lane_slot_bytes: u64, +} + +/// The retained sizes stored fader automation charges (#1646 D4): a plan with automation charges +/// its program once, each fader bank with an automated member one slot per bank lane, and each +/// automated channel one offsets cell, as builtin bank payload. +#[must_use] +pub fn fader_automation_sizes() -> FaderAutomationSizes { + let bytes = |size: usize| u64::try_from(size).unwrap_or(u64::MAX); + FaderAutomationSizes { + program_overhead_bytes: bytes(core::mem::size_of::>()), + offsets_cell_bytes: bytes(core::mem::size_of::>()), + lane_slot_bytes: bytes(core::mem::size_of::>()), + } +} + +/// Compile the session's stored fader automation (#1646 D1, D2): one cell per automated fader +/// lane (a `both` entry gives two), in strip order and then channel order, built once with the +/// fader row's cell rule (`controls::FADER_CELL_RULE`). `None` when no fader lane is automated, so +/// a session without fader automation prepares exactly as before. +/// +/// Each lane's prepared value is its entry's first `start_value` and its `S` the strip's +/// [`controls::EffectiveStrip::offsets_sum`]; the composition `vca_compose_db(value, S)` and its +/// gain conversion run inside [`lane::fpenv::CanonicalFpEnv`], as render's do (D2). +/// +/// # Errors +/// +/// `host.automation.fader` at the entry when a lane's composed value has no fader gain or the +/// program builder refuses its cell (a segment end at or past `2^53` samples), and at +/// `$.automation` when the program does not fit. +fn prepare_fader_automation( + session: &CompiledSession, + effective: &controls::Effective, +) -> Result, BuiltinDiagnostic> { + let model = session.normalized_model(); + if !model + .automation + .iter() + .any(|entry| controls::automation_row_renders(&entry.target)) + { + return Ok(None); + } + let ramp = controls::LiveRamps::for_session(model).for_row(controls::LiveRampRow::VcaOffset); + let refuse = |entry: &session::Automation| { + diag( + "host.automation.fader", + &format!("$.automation[id={}]", entry.id.as_str()), + ) + }; + // One segment table per cell, in program order, with its entry; transient. + let mut tables: Vec<(&session::Automation, Vec)> = Vec::new(); + let mut strips = BTreeMap::new(); + for (strip, values) in model.strips().zip(effective.strips.iter()) { + let entries = controls::fader_automation(model, strip.id); + if entries.iter().all(Option::is_none) { + continue; + } + let mut cells = [None, None]; + for (lane, entry) in entries.into_iter().enumerate() { + let Some(entry) = entry else { continue }; + let segments = controls::stored_segments(entry); + let value = segments.first().ok_or_else(|| refuse(entry))?.start_value; + let offsets_sum = values.offsets_sum[lane]; + { + let _canonical = lane::fpenv::CanonicalFpEnv::enter(); + let composed = session::vca_compose_db(core::hint::black_box(value), offsets_sum); + core::hint::black_box(builtins::checked_fader_gain(composed)) + .map_err(|_| refuse(entry))?; + } + let cell = u32::try_from(tables.len()) + .map_err(|_| diag("host.automation.fader", "$.automation"))?; + cells[lane] = Some(PreparedFaderCell { + cell, + value, + offsets_sum, + offsets_ramp: ramp, + }); + tables.push((entry, segments)); + } + strips.insert(Box::::from(strip.id.as_str()), cells); + } + let descriptions: Vec> = tables + .iter() + .map(|(_, segments)| controls::FADER_CELL_RULE.describe(segments)) + .collect(); + let program_bytes = automation::ProgramBuilder::bytes(&descriptions); + let program = automation::ProgramBuilder::build(&descriptions).map_err(|error| { + let cell = match error { + automation::BuildError::NoSegments { cell } + | automation::BuildError::Segment { cell, .. } + | automation::BuildError::Order { cell, .. } + | automation::BuildError::NonPositiveExponential { cell, .. } + | automation::BuildError::Grid { cell } + | automation::BuildError::Spacing { cell, .. } => Some(cell), + automation::BuildError::TooLarge | automation::BuildError::Allocation => None, + }; + cell.and_then(|cell| tables.get(cell)).map_or_else( + || diag("host.automation.fader", "$.automation"), + |(entry, _)| refuse(entry), + ) + })?; + Ok(Some(FaderAutomationPlan { + program: Arc::new(program), + program_bytes, + strips, + })) +} + /// Words of one input trim cell: the `trim_db` bits and the ramp window. const TRIM_CELL_WORDS: usize = 2; /// Words of one input polarity cell: `0` or `1` and the ramp window. @@ -1124,6 +1389,10 @@ pub struct PreparedBuiltinsSession { tails: Vec<(Box, NodeTailBound)>, requests: Vec, resources: BuiltinResourceEstimate, + /// The session's stored fader automation (#1646), installed on each automated lane's fader + /// owner at lowering; `None` when no fader lane is automated. Its program and every slot and + /// cell lowering builds for it are charged as builtin bank payload (D4). + fader_automation: Option, /// Every route's effective follow gate per source lane, in `model.routes` order, as the /// preparation was given it (decision 17, C7; `controls::Effective::routes`). Control-side data /// like `tails`: graph lowering reads it for the routes' follow gates, so the gate is composed @@ -1521,6 +1790,15 @@ struct FaderBankProcessor { /// One channel per bank lane; `None` for a lane no live control addresses. Always `lanes` long, /// so the lane index is the array index and no lane map is stored. controls: Box<[Option]>, + /// The stored fader automation of the bank's lanes (#1646 D1): one slot per bank lane when a + /// member's fader is automated, `None` on a lane that is not; empty (no allocation) when no + /// member is. + #[expect( + dead_code, + reason = "#1647 drives it in render and #1648 carries it in `adopt_lane`, in the same push \ + (batch R1)" + )] + automation: Box<[Option]>, process_calls: u64, frames_processed: u64, control_delivery: BuiltinControlDelivery, @@ -2420,6 +2698,7 @@ impl TestOnlyFaderMatrixPair { let mut fader = FaderBankProcessor { bank: BuiltinFaderBank::new(Backend::Simd4, width, parameters).expect("fixture"), controls: (0..4).map(|_| None).collect(), + automation: Box::default(), process_calls: 0, frames_processed: 0, control_delivery: BuiltinControlDelivery::BetweenRenderCalls, @@ -3488,7 +3767,13 @@ impl PreparedBuiltinsSession { fn strip_bindings(&mut self) -> Vec { let mut bindings = Vec::with_capacity(self.strips.len() * 3); let control_delivery = self.control_delivery; + let fader_automation = self.fader_automation.take(); for strip in core::mem::take(&mut self.strips) { + // #1646 R17-4: the oracle installs the same lane automation as a fader bank lane. + let automation = fader_automation.as_ref().and_then(|plan| { + plan.strip(&strip.track_id) + .map(|cells| Box::new(LaneFaderAutomation::new(&plan.program, cells))) + }); let StripPreparation { graph_id, parameters, @@ -3511,7 +3796,7 @@ impl PreparedBuiltinsSession { ) = match control { None => ( Box::new(InputProcessor(input)), - Box::new(FaderProcessor(fader)), + Box::new(FaderProcessor(fader, automation)), Box::new(MatrixProcessor(matrix)), ), Some(control) => { @@ -3537,6 +3822,7 @@ impl PreparedBuiltinsSession { .fader .expect("a strip is banked on all three stages or on none"), control_delivery, + automation, #[cfg(any(test, feature = "test-support"))] test_only_owner, }), @@ -3599,13 +3885,81 @@ impl PreparedBuiltinsSession { let Some(width) = BankWidth::for_backend(dispatch) else { return Some(GraphBuiltinBankResourceEstimate::default()); }; + let plan = planned_strip_banks(&self.seal.tracks, dispatch, levels, classes); + self.strip_bank_resource(&plan, width) + } + + /// The retained bytes of the strip banks of `plan` at `width`: each bank kind's processors, + /// members and scratch, and the stored fader automation lowering installs on the fader banks + /// (#1646 D4). The one sum the preflight and the lowering both take. + fn strip_bank_resource( + &self, + plan: &[(TrackStage, Vec>); 3], + width: BankWidth, + ) -> Option { let mut total = GraphBuiltinBankResourceEstimate::default(); - for (stage, groups) in planned_strip_banks(&self.seal.tracks, dispatch, levels, classes) { - let processor_bytes = strip_processor_bytes(stage, width)?; - let kind = builtin_bank_resource(&groups, width, self.seal.quantum, processor_bytes)?; + for (stage, groups) in plan { + let processor_bytes = strip_processor_bytes(*stage, width)?; + let kind = builtin_bank_resource(groups, width, self.seal.quantum, processor_bytes)?; total = add_bank_resource(total, kind)?; } - Some(total) + let fader_groups = plan + .iter() + .find(|(stage, _)| *stage == TrackStage::PostFader) + .map_or(&[][..], |(_, groups)| groups.as_slice()); + add_bank_resource( + total, + self.fader_automation_bank_resource(fader_groups, width)?, + ) + } + + /// The stored fader automation the fader banks of `groups` retain (#1646 D4), as bank payload: + /// the program once (its shared allocation and the builder's report), one lane slot per bank + /// lane of each bank with an automated member, and one offsets cell per automated channel. + /// Nothing when no member is automated. + fn fader_automation_bank_resource( + &self, + groups: &[Box<[GraphNodeId]>], + width: BankWidth, + ) -> Option { + let none = GraphBuiltinBankResourceEstimate::default(); + let Some(automation) = &self.fader_automation else { + return Some(none); + }; + let sizes = fader_automation_sizes(); + let slots_per_bank = sizes + .lane_slot_bytes + .checked_mul(u64::from(width.lanes()))?; + let mut payload_bytes = 0_u64; + for members in groups { + let channels = + automation.automated_channels(members.iter().filter_map( + |member| match member { + GraphNodeId::TrackStage { track_id, .. } => Some(track_id.as_str()), + _ => None, + }, + ))?; + if channels == 0 { + continue; + } + payload_bytes = payload_bytes + .checked_add(slots_per_bank)? + .checked_add(sizes.offsets_cell_bytes.checked_mul(channels)?)?; + } + if payload_bytes == 0 { + return Some(none); + } + let program_bytes = automation.retained_program_bytes()?; + Some(GraphBuiltinBankResourceEstimate { + payload_bytes: payload_bytes.checked_add(program_bytes)?, + // A ceiling on the largest single allocation: the program's arrays are each within its + // report. + largest_allocation_bytes: slots_per_bank + .max(sizes.offsets_cell_bytes) + .max(sizes.program_overhead_bytes) + .max(automation.program_bytes), + ..none + }) } /// Exact pre-bind charge for live scalar owners. Vector backends consume every strip into @@ -3665,20 +4019,56 @@ impl PreparedBuiltinsSession { } else { graph::GraphScalarSplitRuntimeResourceEstimate::default() }; + // #1646 D4 (R17-4): the stored fader automation an unbanked strip's fader owner holds, + // boxed, with its offsets cells, and the program once. + let (automation_bytes, automation_largest) = match &self.fader_automation { + None => (0, 0), + Some(plan) => { + let sizes = fader_automation_sizes(); + let lane = u64::try_from(core::mem::size_of::()).ok()?; + let unbanked = self + .strips + .iter() + .filter(|strip| !banked.contains(&strip.track_id)) + .map(|strip| strip.track_id.as_ref()); + let strips = u64::try_from( + unbanked + .clone() + .filter(|track| plan.strip(track).is_some()) + .count(), + ) + .ok()?; + let channels = plan.automated_channels(unbanked)?; + if strips == 0 { + (0, 0) + } else { + ( + lane.checked_mul(strips)? + .checked_add(sizes.offsets_cell_bytes.checked_mul(channels)?)? + .checked_add(plan.retained_program_bytes()?)?, + lane.max(sizes.offsets_cell_bytes) + .max(sizes.program_overhead_bytes) + .max(plan.program_bytes), + ) + } + } + }; // This owner term adds only the distinct boxed split table. The containing runtime owner // and the per-op split-slot layout are admitted once by the graph compiler. let total_bytes = owners .checked_add(outer_total)? - .checked_add(split_runtime.split_pair_table_bytes)?; + .checked_add(split_runtime.split_pair_table_bytes)? + .checked_add(automation_bytes)?; Some(graph::GraphScalarOwnerResourceEstimate { total_bytes, largest_allocation_bytes: if count == 0 { - 0 + automation_largest } else { fader .max(matrix) .max(if outer_total == 0 { 0 } else { outer }) .max(split_runtime.split_pair_table_bytes) + .max(automation_largest) }, split_pair_table_bytes: split_runtime.split_pair_table_bytes, }) @@ -3762,18 +4152,11 @@ impl PreparedBuiltinsSession { meter_consumers: self.meter_consumers, }; } - let mut resource = GraphBuiltinBankResourceEstimate::default(); - for (stage, groups) in &plan { - let kind = builtin_bank_resource( - groups, - width, - self.seal.quantum, - strip_processor_bytes(*stage, width).expect("bankable stage"), - ) + let resource = self + .strip_bank_resource(&plan, width) .expect("preflighted builtin-bank resource"); - resource = - add_bank_resource(resource, kind).expect("preflighted builtin-bank resource"); - } + // Borrowed: a strip no bank claims keeps its automation for the scalar oracle's bindings. + let fader_automation = &self.fader_automation; let mut bank_inputs: BTreeMap, InputBuiltins> = core::mem::take(&mut self.bank_inputs).into_iter().collect(); @@ -3842,10 +4225,30 @@ impl PreparedBuiltinsSession { .and_then(|control| control.fader.take()); claimed_fader.insert(strip.track_id.clone()); } + // #1646 D1: one slot per bank lane when a member's fader is automated, + // charged by `fader_automation_bank_resource`. + let automation: Box<[Option]> = + match fader_automation.as_ref() { + Some(plan) + if members + .iter() + .any(|member| plan.strip(track_of(member)).is_some()) => + { + (0..lanes) + .map(|lane| { + let track = members.get(lane).map(track_of)?; + let cells = plan.strip(track)?; + Some(LaneFaderAutomation::new(&plan.program, cells)) + }) + .collect() + } + _ => Box::default(), + }; Box::new(FaderBankProcessor { bank: BuiltinFaderBank::new(dispatch, width, parameters) .expect("planner emits 1..=W members at the selected width"), controls: controls.into_boxed_slice(), + automation, process_calls: 0, frames_processed: 0, control_delivery: self.control_delivery, @@ -4747,6 +5150,18 @@ fn prepare_session_builtins_with_live_controls_and_policy( if !diagnostics.is_empty() { return Err(BuiltinDiagnosticSet::sorted(diagnostics)); } + // #1646 D1, D2: every form compiles the stored fader automation here, before phase two (its + // program and cells are graph plan payload, charged by the bank estimate, D4). + let fader_automation = prepare_fader_automation(session, effective) + .map_err(|diagnostic| BuiltinDiagnosticSet::sorted(vec![diagnostic]))?; + // The fader each strip prepares: its effective fader, and on an automated lane the lane's + // prepared value composed with its `S` (D2). + let prepared_fader = |strip: &StripRef<'_>, fader: &controls::EffectiveStrip| { + let fader_db = fader_automation.as_ref().map_or(fader.fader_db, |plan| { + plan.fader_db(strip.id.as_str(), fader.fader_db) + }); + (fader_db, fader.mute) + }; let resources = resource_plan.expect("validated resource plan").report; // #1242 D2, decision 17 C7: lowering bakes each strip's effective fader and mute, which the // caller computed (strip `i` is entry `i`). @@ -4786,7 +5201,7 @@ fn prepare_session_builtins_with_live_controls_and_policy( .map(|(strip, fader)| { strip_parameters( &strip, - (fader.fader_db, fader.mute), + prepared_fader(&strip, fader), caps.maximum_smoothing_samples, ) .expect("preflighted parameters") @@ -4813,9 +5228,11 @@ fn prepare_session_builtins_with_live_controls_and_policy( let mut track_controls = Vec::with_capacity(controls.len()); let mut control_seal: Vec<(Box, bool)> = Vec::with_capacity(controls.len()); for (strip, fader) in session.normalized_model().strips().zip(effective_faders) { + // An automated lane's prepared value is in the fader domain: `vca_compose_db` clamps and + // `prepare_fader_automation` checked its gain. let parameters = strip_parameters( &strip, - (fader.fader_db, fader.mute), + prepared_fader(&strip, fader), caps.maximum_smoothing_samples, ) .expect("preflighted parameters"); @@ -4962,6 +5379,7 @@ fn prepare_session_builtins_with_live_controls_and_policy( requests: request_seals, resources, route_follow: effective.routes.iter().map(|route| route.follow).collect(), + fader_automation, }) } @@ -5411,7 +5829,16 @@ impl GraphRuntimeProcessor for InputProcessor { } } #[cfg(any(test, feature = "test-support"))] -struct FaderProcessor(FaderMuteBuiltins); +struct FaderProcessor( + FaderMuteBuiltins, + /// The strip's stored fader automation (#1646 D1, R17-4), boxed so a strip without it keeps + /// one word. + #[expect( + dead_code, + reason = "#1647 drives it in the oracle's render, in the same push (batch R1)" + )] + Option>, +); #[cfg(any(test, feature = "test-support"))] impl GraphRuntimeProcessor for FaderProcessor { fn process(&mut self, block: GraphBindingBlock<'_>) -> Result<(), RenderError> { @@ -5598,6 +6025,12 @@ struct LiveControlFaderProcessor { fader: FaderMuteRampBuiltins, control: FaderReader, control_delivery: BuiltinControlDelivery, + /// The strip's stored fader automation (#1646 D1, R17-4): the bank's event driver, per strip. + #[expect( + dead_code, + reason = "#1647 drives it in the oracle's render, in the same push (batch R1)" + )] + automation: Option>, #[cfg(any(test, feature = "test-support"))] test_only_owner: u16, } @@ -11244,6 +11677,7 @@ mod tests { controls: core::iter::once(Some(fader_rx)) .chain((1..width.lanes()).map(|_| None)) .collect(), + automation: Box::default(), process_calls: 0, frames_processed: 0, control_delivery: BuiltinControlDelivery::BetweenRenderCalls, @@ -11698,6 +12132,7 @@ mod tests { controls: core::iter::once(Some(reader)) .chain((1..4).map(|_| None)) .collect(), + automation: Box::default(), process_calls: 0, frames_processed: 0, control_delivery: BuiltinControlDelivery::BetweenRenderCalls, @@ -11754,6 +12189,7 @@ mod tests { controls: core::iter::once(Some(reader)) .chain((1..width.lanes()).map(|_| None)) .collect(), + automation: Box::default(), process_calls: 0, frames_processed: 0, control_delivery: BuiltinControlDelivery::BetweenRenderCalls, @@ -11931,6 +12367,7 @@ mod tests { fader: FaderMuteRampBuiltins::new(parameters).expect("fader"), control: fader_rx, control_delivery: fader_delivery, + automation: None, #[cfg(any(test, feature = "test-support"))] test_only_owner: u16::MAX, }), @@ -13038,7 +13475,8 @@ mod tests { ); let fader_fields = core::mem::size_of::() + core::mem::size_of::() - + core::mem::size_of::(); + + core::mem::size_of::() + + core::mem::size_of::>>(); let matrix_fields = core::mem::size_of::() + core::mem::size_of::() + core::mem::size_of::(); @@ -15016,6 +15454,7 @@ mod tests { bank: BuiltinFaderBank::new(backend, width, parameters.clone()) .expect("fader bank"), controls, + automation: Box::default(), process_calls: 0, frames_processed: 0, control_delivery: BuiltinControlDelivery::BetweenRenderCalls, diff --git a/crates/capi/src/runtime/tests.rs b/crates/capi/src/runtime/tests.rs index 47c15865d..b65fcde6b 100644 --- a/crates/capi/src/runtime/tests.rs +++ b/crates/capi/src/runtime/tests.rs @@ -6301,3 +6301,193 @@ mod session_seek_declares { assert_eq!(rig.epoch(), epoch); } } + +/// #1646 gate 1: the sessions of one case, all on [`stateless_session`] at 48 kHz. +struct FlatFaderCase { + label: &'static str, + /// A flat fader automation on one track, whose static fader is another value. + automated: session::SessionModel, + /// The same session with the track's static fader at the flat value and no automation. + reference: session::SessionModel, +} + +/// A `linear` segment from `value` to `value` on `lanes` of track `track`'s fader (builtin row 5). +fn flat_fader_entry( + model: &session::SessionModel, + track: usize, + value: f32, +) -> session::Automation { + session::Automation { + id: session::StableId::parse(&format!("fader-{track}")).expect("automation ID"), + target: session::ControlAddress { + owner: session::Owner::Strip(model.tracks[track].id.clone()), + section: session::Section::Builtins, + control: 5, + lane: session::ParameterChannel::Both, + }, + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: 1 << 20, + start_value: value, + end_value: value, + unit: session::ParameterUnit::Db, + }], + } +} + +/// A VCA `id` with offset `offset` dB on both lanes, holding `members`. +fn flat_case_vca(id: &str, offset: f32, members: &[&session::StableId]) -> session::Vca { + session::Vca { + id: session::StableId::parse(id).expect("VCA ID"), + fader: session::DualMonoFader { + left_db: offset, + right_db: offset, + left_mute: false, + right_mute: false, + }, + members: members.iter().map(|member| (*member).clone()).collect(), + } +} + +/// Gate 1's crafted three-VCA value and offsets (in ascending VCA-ID order), for which the two +/// composition orders of README F16 round to different gain bits. +const CRAFTED_VALUE: u32 = 0x405a_f905; +const CRAFTED_OFFSETS: [u32; 3] = [0xc1af_fc6c, 24.0_f32.to_bits(), 0x2624_8cd2]; + +/// The four cases of #1646 gate 1: a flat value on a track no VCA reaches; a `both` entry on a +/// track one `+3` dB VCA reaches; the crafted value on a track three VCAs reach; and `-0.0` dB on +/// a track neither of two VCAs reaches (the cell's `S = +0.0`). Each automated track's own static +/// fader is another value, so the flat value is heard only if the automation prepares it. +fn flat_fader_cases() -> Vec { + let base = stateless_session(48_000); + let case = |label: &'static str, + track: usize, + value: f32, + static_db: f32, + vcas: &dyn Fn(&session::SessionModel) -> Vec| { + let mut reference = base.clone(); + reference.vcas = vcas(&reference); + let fader = &mut reference.tracks[track].fader; + (fader.left_db, fader.right_db) = (value, value); + let mut automated = reference.clone(); + let fader = &mut automated.tracks[track].fader; + (fader.left_db, fader.right_db) = (static_db, static_db); + automated.automation = vec![flat_fader_entry(&automated, track, value)]; + FlatFaderCase { + label, + automated, + reference, + } + }; + vec![ + case("no VCA", 0, -7.25, 0.0, &|_| Vec::new()), + case("one +3 dB VCA", 1, -7.25, 0.0, &|model| { + vec![flat_case_vca("v-plus", 3.0, &[&model.tracks[1].id])] + }), + case( + "three VCAs, crafted", + 1, + f32::from_bits(CRAFTED_VALUE), + 0.0, + &|model| { + ["va", "vb", "vc"] + .into_iter() + .zip(CRAFTED_OFFSETS) + .map(|(id, offset)| { + flat_case_vca(id, f32::from_bits(offset), &[&model.tracks[1].id]) + }) + .collect() + }, + ), + case("-0.0 dB, two VCAs elsewhere", 2, -0.0, -3.0, &|model| { + vec![ + flat_case_vca("va", -1.0, &[&model.tracks[0].id]), + flat_case_vca("vb", 2.0, &[&model.tracks[0].id, &model.tracks[1].id]), + ] + }), + ] +} + +/// The crafted case composes to different gain bits in the two orders, so a render that keeps +/// the member-first order cannot equal the static path, which sums the offsets first (#1646 D6). +fn assert_the_crafted_orders_differ() { + let value = f64::from(f32::from_bits(CRAFTED_VALUE)); + let offsets = CRAFTED_OFFSETS.map(|bits| f64::from(f32::from_bits(bits))); + let member_first = offsets.iter().fold(value, |sum, offset| sum + offset) as f32; + let offsets_first = session::vca_compose_db( + f32::from_bits(CRAFTED_VALUE), + session::vca_offsets_sum(CRAFTED_OFFSETS.map(f32::from_bits)).expect("three offsets"), + ); + assert_eq!(member_first.to_bits(), 0x40ad_8ad3); + assert_eq!(offsets_first.to_bits(), 0x40ad_8ad2); + let gain = |db: f32| { + builtins::checked_fader_gain(db) + .expect("in domain") + .to_bits() + }; + assert_ne!( + gain(member_first), + gain(offsets_first), + "the two orders give different gain bits" + ); +} + +/// Renders `model` on the C ABI for `blocks` blocks of the continuity signal. +fn render_flat_case_c(model: &session::SessionModel, blocks: u64) -> Vec> { + let document = session::canonical_session_json(model).expect("canonical"); + let (c_session, c_plan) = boxed_c_children_with_limits( + &document, + CompileLimits { + maximum_vcas: 8, + ..limits() + }, + ); + let (left, right) = continuity_block(0, 0); + submit_c(c_session, 1, 0, 48_000, &left, &right, false); + let blocks = feed_and_render_c(c_session, c_plan, 48_000, 0, blocks); + crate::ffi::test_plan_destroy(c_plan); + crate::ffi::test_session_destroy(c_session); + sample_bits(&blocks) +} + +/// #1646 gate 1 (C ABI): a flat stored fader automation renders, bit for bit, the static fader at +/// its value, for 64 blocks, with no VCA, under one `+3` dB VCA, under the crafted three-VCA +/// offsets, and at `-0.0` dB on a lane no VCA reaches. Each automated session also renders +/// differently from itself without the automation, so the value is the automation's. +/// +/// Test value: red if preparation composes the offsets in another order than the static path, if +/// the `+0.0` of a lane no VCA reaches changes the gain bits, if the prepared value is not the +/// curve's, or if the C ABI converts dB differently from the static fader. +#[test] +fn a_flat_fader_automation_renders_the_static_fader_bits_on_the_c_abi() { + const BLOCKS: u64 = 64; + assert_the_crafted_orders_differ(); + for case in flat_fader_cases() { + let automated = render_flat_case_c(&case.automated, BLOCKS); + let reference = render_flat_case_c(&case.reference, BLOCKS); + let mut unautomated = case.automated.clone(); + unautomated.automation.clear(); + let without = render_flat_case_c(&unautomated, BLOCKS); + assert!( + reference + .iter() + .flatten() + .any(|bits| f32::from_bits(*bits) != 0.0), + "{}: the reference plays", + case.label + ); + assert_ne!( + without, reference, + "{}: the automation is heard", + case.label + ); + for (block, (automated, reference)) in automated.iter().zip(&reference).enumerate() { + assert_eq!( + automated, reference, + "{}: block {block} renders the static fader's bits", + case.label + ); + } + } +} diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index fd6971bbd..49322c061 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -1,7 +1,7 @@ //! The row rule of a stored automation (decision 17, C10; #1566 D6): one function that holds every //! stored automation of a session to the control it names. -use automation::{Segment, Shape, value_at}; +use automation::{CellDescription, Completion, JumpLength, SeekMode, Segment, Shape, value_at}; use effect_contract::ParameterUnit; use session::{ Automation, AutomationSegment, AutomationShape, ControlAddress, Owner, Section, SessionModel, @@ -9,7 +9,7 @@ use session::{ }; use crate::ControlKind; -use crate::builtin_rows::{HPF_HZ, LPF_HZ}; +use crate::builtin_rows::{FADER_DB, HPF_HZ, LPF_HZ}; use crate::descriptor::{ControlDescriptor, Lanes, check}; use crate::effect_rows::{has_rows, session_unit}; use crate::error::ControlError; @@ -279,6 +279,98 @@ impl Piece { } } +/// How the cells of one row's stored automation move in render (#1637): the parameters of every +/// cell compiled from an entry on the row. Each rendering row has one, written here and nowhere +/// else, so a stage never spells a row's grid or ramp rule (#1646 departure 6). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CellRule { + /// `L`, the grid ramp in samples; the grid period is the larger of 64 and `L`. + pub grid_ramp: u32, + /// Where a jump's ramp length comes from. + pub jump: JumpLength, + /// Where a ramp started at `τ` completes. + pub completion: Completion, + /// A target whose ramp cannot restart while one runs. + pub no_restart: bool, + /// How a seek lands on the cell. + pub seek: SeekMode, +} + +impl CellRule { + /// The cell description of `segments` under this rule, the input of + /// [`automation::ProgramBuilder`]. + #[must_use] + pub const fn describe(self, segments: &[Segment]) -> CellDescription<'_> { + CellDescription { + segments, + grid_ramp: self.grid_ramp, + grid: if self.grid_ramp > 64 { + self.grid_ramp + } else { + 64 + }, + jump: self.jump, + completion: self.completion, + no_restart: self.no_restart, + } + } +} + +/// The fader row's cell rule (#1646 D1): grid period and grid ramp 64, completion `End` (the +/// fader ramp assigns its target on its last frame), the jump length of the block +/// (`controls::LiveRamps::for_row(LiveRampRow::Fader)`, resolved by the caller), restartable, and +/// an exact seek landing (a builtin cell). +pub const FADER_CELL_RULE: CellRule = CellRule { + grid_ramp: 64, + jump: JumpLength::Block, + completion: Completion::End, + no_restart: false, + seek: SeekMode::Exact, +}; + +/// Whether a stored automation of `target` renders (#1646, #1647): today the fader row of a strip +/// (builtin row 5). An entry on any other row is validated and kept in the session but compiles +/// to no cell. Every rendering slice adds its row here, so preparation and the live classifier +/// (#1648) read one list. +#[must_use] +pub fn automation_row_renders(target: &ControlAddress) -> bool { + matches!(target.owner, Owner::Strip(_)) + && target.section == Section::Builtins + && target.control == FADER_DB +} + +/// The stored automation entries that drive strip `strip`'s fader, per lane (`[left, right]`): a +/// `both` entry drives both lanes, a `left` or `right` entry its own. The session validator allows +/// at most one entry per lane (#1626), so each lane has at most one. +#[must_use] +pub fn fader_automation<'a>( + model: &'a SessionModel, + strip: &StableId, +) -> [Option<&'a Automation>; 2] { + let mut lanes = [None, None]; + for automation in &model.automation { + let target = &automation.target; + if !automation_row_renders(target) + || !matches!(&target.owner, Owner::Strip(owner) if owner == strip) + { + continue; + } + for (index, lane) in [Lane::Left, Lane::Right].iter().enumerate() { + if (target.lane == *lane || target.lane == Lane::Both) && lanes[index].is_none() { + lanes[index] = Some(automation); + } + } + } + lanes +} + +/// An entry's segments in the evaluator's type, in table order: the one conversion of a stored +/// segment, which preparation and the order rule read. +#[must_use] +pub fn stored_segments(entry: &Automation) -> Vec { + entry.segments.iter().map(stored_segment).collect() +} + fn stored_segment(segment: &AutomationSegment) -> Segment { Segment { start: segment.start_sample, @@ -303,7 +395,7 @@ impl<'a> Curve<'a> { match entry { Some(entry) => Self::Automated { entry, - segments: entry.segments.iter().map(stored_segment).collect(), + segments: stored_segments(entry), }, None => Self::Static(value), } diff --git a/crates/controls/src/builtin_rows.rs b/crates/controls/src/builtin_rows.rs index 4adf0702a..2ccd42c96 100644 --- a/crates/controls/src/builtin_rows.rs +++ b/crates/controls/src/builtin_rows.rs @@ -21,7 +21,7 @@ const POLARITY_INVERT: u32 = 1; const TRIM_DB: u32 = 2; pub(crate) const HPF_HZ: u32 = 3; pub(crate) const LPF_HZ: u32 = 4; -const FADER_DB: u32 = 5; +pub(crate) const FADER_DB: u32 = 5; const MUTE: u32 = 6; const MATRIX_LL: u32 = 7; const MATRIX_LR: u32 = 8; diff --git a/crates/controls/src/effective.rs b/crates/controls/src/effective.rs index 844f50ef7..53a7f3e0f 100644 --- a/crates/controls/src/effective.rs +++ b/crates/controls/src/effective.rs @@ -198,6 +198,11 @@ pub struct EffectiveStrip { pub mute: [bool; 2], /// Whether any reaching VCA mutes the lane. pub vca_mute: [bool; 2], + /// `S`, the lane's offset sum (#1646 D1, D6): [`session::vca_offsets_sum`] of the reaching + /// VCAs' offsets, in ascending VCA-ID order, and `+0.0` when no VCA reaches the lane. A stored + /// fader automation value `v` renders as `session::vca_compose_db(v, S)`; the static path + /// composes the member with the same sum (`fader_db`). + pub offsets_sum: [f64; 2], } /// One route's effective values. @@ -305,7 +310,8 @@ pub fn solo_term(inputs: &impl EffectiveInputs, strip: usize, any_solo: bool) -> /// One strip's effective values. `any_solo` is [`any_solo`]'s result for the same inputs. /// /// Per lane: `fader_db = vca_effective_db(own, the reaching VCAs' offsets in ascending VCA-ID -/// order)`; `vca_mute` = any reaching VCA mutes; `mute = own || vca_mute || solo_term` with +/// order)`; `offsets_sum` = their [`session::vca_offsets_sum`], or `+0.0` with none; `vca_mute` = +/// any reaching VCA mutes; `mute = own || vca_mute || solo_term` with /// `solo_term = any_solo && !solo_safe && !soloed`. #[must_use] pub fn effective_strip( @@ -329,6 +335,7 @@ pub fn effective_strip( } for lane in 0..2 { out.fader_db[lane] = sums[lane].compose(own_db[lane]); + out.offsets_sum[lane] = sums[lane].sum().unwrap_or(0.0); out.mute[lane] = own_mute[lane] || out.vca_mute[lane] || solo_term; } out diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index 2364f676b..32048154d 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -45,8 +45,9 @@ mod vca; mod view; pub use automation::{ - ControlDiagnostic, automation_diagnostics, filter_statics_changed_under_automation, - filter_under_automation, + CellRule, ControlDiagnostic, FADER_CELL_RULE, automation_diagnostics, automation_row_renders, + fader_automation, filter_statics_changed_under_automation, filter_under_automation, + stored_segments, }; pub use descriptor::{ AutomationRule, Choice, ControlDescriptor, ControlScope, DomainRule, Lanes, Motion, check, diff --git a/crates/controls/tests/composition.rs b/crates/controls/tests/composition.rs index 50ce3ebe0..7abc08d4f 100644 --- a/crates/controls/tests/composition.rs +++ b/crates/controls/tests/composition.rs @@ -191,11 +191,14 @@ fn vca_reach_counts_a_diamond_once_and_includes_nested_parents_in_id_order() { ], mute: [true, vocal.right_mute], vca_mute: [true, false], + offsets_sum: [-1.0, -1.0], }, EffectiveStrip { fader_db: [1.0 - 2.0 - 3.0 - 4.0, -2.5 + 0.125 + 0.25 + 0.5], mute: [false, true], vca_mute: [false, true], + // The reach in ascending VCA ID: `a-mid`, `b-mid`, `z-top`. + offsets_sum: [-2.0 - 3.0 - 4.0, 0.125 + 0.25 + 0.5], }, EffectiveStrip { fader_db: [ @@ -207,15 +210,21 @@ fn vca_reach_counts_a_diamond_once_and_includes_nested_parents_in_id_order() { model.tracks[2].fader.right_mute, ], vca_mute: [false, false], + // No VCA reaches `t1`: `S` is `+0.0`. + offsets_sum: [0.0, 0.0], }, EffectiveStrip { fader_db: [bus.left_db - 3.0 - 4.0, bus.right_db + 0.25 + 0.5], mute: [bus.left_mute, true], vca_mute: [false, true], + offsets_sum: [-3.0 - 4.0, 0.25 + 0.5], }, ]; let actual = effective(&model, &MonitorOverlay::default()); assert_eq!(&*actual.strips, expected.as_slice()); + // `+0.0`, not `-0.0`, which `==` would also accept: a stored fader value of `-0.0` composes + // to `+0.0` dB, whose gain bits equal the static `-0.0`'s (#1646 D1). + assert_eq!(actual.strips[2].offsets_sum.map(f64::to_bits), [0, 0]); } /// Gate 1, termination. An unvalidated model whose VCAs form a two-VCA cycle (`c1` holds `c2` and diff --git a/crates/controls/tests/effective.rs b/crates/controls/tests/effective.rs index f8fc809cf..27500e99a 100644 --- a/crates/controls/tests/effective.rs +++ b/crates/controls/tests/effective.rs @@ -238,6 +238,7 @@ fn effective_into_allocates_nothing_and_refuses_a_wrong_shape() { fader_db: [9.0; 2], mute: BOTH_ON, vca_mute: BOTH_ON, + offsets_sum: [9.0; 2], }); let before = wrong.clone(); assert_eq!( diff --git a/crates/host-core/Cargo.toml b/crates/host-core/Cargo.toml index faf3bd85b..f883013d0 100644 --- a/crates/host-core/Cargo.toml +++ b/crates/host-core/Cargo.toml @@ -39,6 +39,8 @@ session.workspace = true source.workspace = true [dev-dependencies] +# #1646 gate 2: the program builder's byte report is the charge a fader automation adds. +automation.workspace = true bench-support.workspace = true builtins.workspace = true builtins-compiler = { workspace = true, features = ["test-support"] } diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs new file mode 100644 index 000000000..adf53e715 --- /dev/null +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -0,0 +1,278 @@ +//! Stored fader automation at preparation (#1646): its bytes are charged as builtin bank payload +//! (gate 2), and a fader automation keeps a mono track's collapse (gate 3). Gate 1 (flat renders +//! the static bits) is on the two hosts: `crates/capi/src/runtime/tests.rs` and +//! `hosts/host-web/src/tests.rs`. + +use automation::ProgramBuilder; +use builtins_compiler::fader_automation_sizes; +use engine::realtime::{PlanUnitEligibility, PlanarBufferMut, RenderIo, RenderTime}; +use host_core::{ + HostPrepareCaps, HostPrepareReport, HostShapePolicy, PreparedHost, SourceSubmission, + prepare_host_session, +}; +use session::{ + Automation, AutomationSegment, AutomationShape, ControlAddress, Owner, ParameterChannel, + ParameterUnit, Section, SessionModel, StableId, canonical_session_json, parse_session_json, +}; + +/// Eight tracks on one two-channel source, one banked parametric EQ each as the console slot. +const BANK: &str = include_str!("../../../fixtures/session/v1/parametric-eq-bank-console.json"); +const QUANTUM: usize = 128; +const RATE: u32 = 48_000; +const BLOCKS: usize = 16; +/// The fader row (builtin parameter 5). +const FADER_DB: u32 = 5; + +fn caps(maximum_graph_session_plus_plan_bytes: u64) -> HostPrepareCaps { + HostPrepareCaps { + shape: HostShapePolicy::AnyLaunchRate, + source_ring_frames: 4_096, + maximum_automation_spans_per_block: 128, + maximum_tracks: 100, + maximum_submixes: 100, + maximum_vcas: 100, + maximum_vca_reach_pairs: u64::MAX, + maximum_sources: 100, + maximum_routes: 100, + maximum_effects: 100, + maximum_graph_session_plus_plan_bytes, + maximum_source_total_bytes: 10_000_000, + maximum_source_overhead_bytes: 10_000_000, + maximum_effect_state_bytes: 100_000_000, + maximum_effect_scratch_bytes: 100_000_000, + maximum_builtin_retained_bytes: 100_000_000, + maximum_named_allocation_bytes: 100_000_000, + maximum_meter_streams: 64, + maximum_meter_items: 1 << 16, + maximum_meter_bytes: 1 << 24, + } +} + +fn id(text: &str) -> StableId { + StableId::parse(text).expect("stable id") +} + +fn bank_model() -> SessionModel { + let mut model = parse_session_json(BANK).expect("bank fixture parses"); + model.automation.clear(); + model +} + +/// A flat fader automation at `value` dB on `lane` of track `track`: one `linear` segment from +/// `value` to `value`. +fn flat_fader( + model: &SessionModel, + track: usize, + lane: ParameterChannel, + value: f32, +) -> Automation { + Automation { + id: id(&format!("fader-{track}")), + target: ControlAddress { + owner: Owner::Strip(model.tracks[track].id.clone()), + section: Section::Builtins, + control: FADER_DB, + lane, + }, + segments: vec![AutomationSegment { + shape: AutomationShape::Linear, + start_sample: 0, + end_sample: 48_000, + start_value: value, + end_value: value, + unit: ParameterUnit::Db, + }], + } +} + +fn prepare(model: &SessionModel, maximum: u64) -> Result { + let document = canonical_session_json(model).expect("canonical"); + prepare_host_session(&document, &caps(maximum)) + .map(|(_, prepared)| prepared) + .map_err(|failure| String::from_utf8_lossy(failure.as_bytes()).into_owned()) +} + +/// The bytes the host admits against `maximum_graph_session_plus_plan_bytes` for a plan prepared +/// with no live controls and no spectrum: the graph row, the compiled model and the carry program. +fn admitted(report: &HostPrepareReport) -> u64 { + report.graph_session_plus_plan_bytes + + report.session_model_bytes + + report.spectrum_capture_retained_bytes + + report.route_control_resources.total_bytes + + report.carry_program_retained_bytes +} + +/// The bank width the plan was built at: the widest bank chain's lane count. +fn width(units: &[PlanUnitEligibility]) -> u64 { + units + .iter() + .filter(|unit| unit.banked) + .map(|unit| unit.lane_tracks.len() as u64) + .max() + .expect("the session banks") +} + +/// Gate 2: the same session with and without one automated fader lane. `builtin_bank_bytes`, +/// `graph_incremental_plan_bytes` and `graph_session_plus_plan_bytes` each differ by exactly the +/// lane's charge: the builder's byte report for its program and the program's shared allocation, +/// one automation slot per lane of its fader bank, and its offsets cell (D4). A cap one byte below +/// the admitted total refuses the session with `host.graph.resource.limit`, and the cap at the +/// total prepares it. +/// +/// Test value: red if any term of the charge (the program, the bank slots or the offsets cell) +/// escapes the graph rows, the caller's cap or (through `graph_session_plus_plan_bytes`) the C +/// ABI's replacement peak, or if a session without automation pays for it. +#[test] +fn a_fader_automation_is_charged_to_the_graph_plan_rows_and_the_cap() { + let plain = bank_model(); + let mut automated = plain.clone(); + let entry = flat_fader(&automated, 2, ParameterChannel::Left, -7.25); + let segments = controls::stored_segments(&entry); + automated.automation = vec![entry]; + + let base = prepare(&plain, u64::MAX).expect("the plain session prepares"); + let with = prepare(&automated, u64::MAX).expect("the automated session prepares"); + let sizes = fader_automation_sizes(); + let lanes = width(&with.plan.unit_eligibility()); + let program = ProgramBuilder::bytes(&[controls::FADER_CELL_RULE.describe(&segments)]); + assert!(program > 0, "the program holds the segment"); + let charge = program + + sizes.program_overhead_bytes + + lanes * sizes.lane_slot_bytes + + sizes.offsets_cell_bytes; + for (row, before, after) in [ + ( + "builtin_bank_bytes", + base.report.builtin_bank_bytes, + with.report.builtin_bank_bytes, + ), + ( + "graph_incremental_plan_bytes", + base.report.graph_incremental_plan_bytes, + with.report.graph_incremental_plan_bytes, + ), + ( + "graph_session_plus_plan_bytes", + base.report.graph_session_plus_plan_bytes, + with.report.graph_session_plus_plan_bytes, + ), + ] { + assert_eq!( + after - before, + charge, + "{row}: {before} -> {after}, the charge is {charge} at {lanes} lanes" + ); + } + + let total = admitted(&with.report); + assert!( + admitted(&base.report) < total - 1, + "the plain session fits the cap the automated one exceeds" + ); + drop((base, with)); + prepare(&automated, total).expect("the cap at the admitted total prepares"); + let Err(refusal) = prepare(&automated, total - 1) else { + panic!("one byte below the admitted total refuses"); + }; + assert!( + refusal.contains("host.graph.resource.limit"), + "refused with the graph cap's code: {refusal}" + ); +} + +/// A deterministic broadband signal: every sample differs, so a copied plane cannot hide. +fn signal(index: usize) -> f32 { + let mut state = (index as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ 0x1234_5678; + state ^= state >> 33; + state = state.wrapping_mul(0xff51_afd7_ed55_8ccd); + state ^= state >> 29; + (((state >> 40) as f32) / 16_777_216.0) - 0.5 +} + +/// `[left, right]` output bits over [`BLOCKS`] blocks, the source's two planes carrying the same +/// PCM, and the plan's `[blocks rendered collapsed, chains armed with a prefix]`. +fn render(model: &SessionModel) -> ([Vec; 2], [u64; 2]) { + let mut prepared = prepare(model, 100_000_000).expect("prepares"); + let mut planes = [Vec::new(), Vec::new()]; + for block in 0..BLOCKS { + let base = block * QUANTUM; + let plane: Vec = (0..QUANTUM).map(|frame| signal(base + frame)).collect(); + prepared + .sources + .submit( + b"fixture-source", + SourceSubmission { + generation: 1, + start_frame: base as u64, + sample_rate_hz: RATE, + planes: &[&plane, &plane], + frames: QUANTUM as u32, + end_of_region: false, + }, + ) + .expect("source block"); + let mut samples = [0.0_f32; QUANTUM * 2]; + let output = + PlanarBufferMut::try_new(&mut samples, 2, QUANTUM, QUANTUM).expect("output planes"); + prepared + .plan + .render( + RenderIo { output }, + RenderTime { + absolute_sample: base as u64, + }, + ) + .expect("render"); + planes[0].extend(samples[..QUANTUM].iter().map(|value| value.to_bits())); + planes[1].extend(samples[QUANTUM..].iter().map(|value| value.to_bits())); + } + (planes, prepared.plan.bank_collapse_counters()) +} + +/// Gate 3 (D3): a mono track (source channels `0, 0`) with a flat `left`-only fader automation at +/// a value its right lane does not hold keeps its collapse: the plan renders as many collapsed +/// blocks as the same session without the automation, and its output equals, bit for bit, the +/// dual render of a twin whose track reads channels `0, 1`, which carry the same PCM. +/// +/// Test value: red if fader automation declines the collapse (a plane per track for no audible +/// change), or if the collapsed render of the automated lane differs from its dual render. +#[test] +fn a_left_only_fader_automation_keeps_a_mono_tracks_collapse() { + let mut mono = bank_model(); + for track in &mut mono.tracks { + track.right_source_channel = track.left_source_channel; + } + mono.tracks[0].fader.right_db = -2.0; + let mut automated = mono.clone(); + automated.automation = vec![flat_fader(&automated, 0, ParameterChannel::Left, -9.5)]; + let mut twin = automated.clone(); + twin.tracks[0].right_source_channel = 1; + assert_ne!( + twin.tracks[0].left_source_channel, twin.tracks[0].right_source_channel, + "the twin reads two channels" + ); + + let (plain, plain_counters) = render(&mono); + let (collapsed, counters) = render(&automated); + let (dual, _) = render(&twin); + assert!(counters[0] > 0, "the mono session collapses: {counters:?}"); + assert_ne!(plain[0], collapsed[0], "the automated lane is heard"); + assert_eq!( + counters, plain_counters, + "the fader automation changes no collapse" + ); + assert!( + dual[0] != dual[1], + "the automated left lane differs from the right, so a copied plane cannot hide" + ); + for plane in 0..2 { + let first = collapsed[plane] + .iter() + .zip(&dual[plane]) + .position(|(a, b)| a != b); + assert_eq!( + first, None, + "plane {plane}: the collapsed render equals the twin's dual render" + ); + } +} diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index b35ff90f5..7829aa42f 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -9968,3 +9968,192 @@ mod session_seek { assert_eq!(rig.timeline(swap), 3_000); } } + +/// #1646 gate 1 (browser): the parity fixture's first three tracks, with empty console sections, +/// no inserts and the input filters off, so the static fader is the one value a case moves. +fn flat_fader_base() -> session::SessionModel { + let mut model = parse_session_json(include_str!( + "../../../fixtures/session/v1/parametric-eq-nine-track.json" + )) + .expect("accepted nine-track fixture"); + model.tracks.truncate(3); + model.routes.truncate(3); + model.console.pre_insert.clear(); + model.console.post_insert.clear(); + for track in &mut model.tracks { + track.console.clear(); + track.inserts.effects.clear(); + for builtins in [&mut track.builtins.left, &mut track.builtins.right] { + builtins.hpf_hz = 0.0; + builtins.lpf_hz = 0.0; + } + } + model +} + +/// A VCA `id` at `offset` dB on both lanes, holding the tracks `members`. +fn flat_fader_vca( + model: &session::SessionModel, + id: &str, + offset: f32, + members: &[usize], +) -> session::Vca { + session::Vca { + id: session::StableId::parse(id).expect("VCA ID"), + fader: session::DualMonoFader { + left_db: offset, + right_db: offset, + left_mute: false, + right_mute: false, + }, + members: members + .iter() + .map(|track| model.tracks[*track].id.clone()) + .collect(), + } +} + +/// One VCA of a gate 1 case: its ID, its offset in dB and the tracks it holds. +type FlatFaderVca<'a> = (&'a str, f32, &'a [usize]); + +/// `(label, automated, reference)`: the automated session holds a `both` flat fader automation +/// at `value` on `track`, whose static fader is `static_db`; the reference holds the static fader +/// at `value` and no automation. The four cases are the C ABI's gate 1 cases +/// (`crates/capi/src/runtime/tests.rs`, `flat_fader_cases`). +fn flat_fader_sessions() -> Vec<(&'static str, session::SessionModel, session::SessionModel)> { + let crafted_offsets = [0xc1af_fc6c, 24.0_f32.to_bits(), 0x2624_8cd2].map(f32::from_bits); + let crafted = f32::from_bits(0x405a_f905); + let cases: [(&str, usize, f32, f32, &[FlatFaderVca]); 4] = [ + ("no VCA", 0, -7.25, 0.0, &[]), + ("one +3 dB VCA", 1, -7.25, 0.0, &[("v-plus", 3.0, &[1])]), + ( + "three VCAs, crafted", + 1, + crafted, + 0.0, + &[ + ("va", crafted_offsets[0], &[1]), + ("vb", crafted_offsets[1], &[1]), + ("vc", crafted_offsets[2], &[1]), + ], + ), + ( + "-0.0 dB, two VCAs elsewhere", + 2, + -0.0, + -3.0, + &[("va", -1.0, &[0]), ("vb", 2.0, &[0, 1])], + ), + ]; + // The crafted case: the two composition orders give different gain bits, so a render that + // kept the member-first order could not equal the static path (#1646 D6). + let member_first = crafted_offsets + .iter() + .fold(f64::from(crafted), |sum, offset| sum + f64::from(*offset)) + as f32; + let offsets_first = session::vca_compose_db( + crafted, + session::vca_offsets_sum(crafted_offsets).expect("three offsets"), + ); + let gain = |db: f32| { + builtins::checked_fader_gain(db) + .expect("in domain") + .to_bits() + }; + assert_ne!(gain(member_first), gain(offsets_first)); + + let base = flat_fader_base(); + cases + .into_iter() + .map(|(label, track, value, static_db, vcas)| { + let mut reference = base.clone(); + reference.vcas = vcas + .iter() + .map(|(id, offset, members)| flat_fader_vca(&reference, id, *offset, members)) + .collect(); + let fader = &mut reference.tracks[track].fader; + (fader.left_db, fader.right_db) = (value, value); + let mut automated = reference.clone(); + let fader = &mut automated.tracks[track].fader; + (fader.left_db, fader.right_db) = (static_db, static_db); + automated.automation = vec![session::Automation { + id: session::StableId::parse("fader-ride").expect("automation ID"), + target: session::ControlAddress { + owner: session::Owner::Strip(automated.tracks[track].id.clone()), + section: session::Section::Builtins, + control: 5, + lane: session::ParameterChannel::Both, + }, + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: 1 << 20, + start_value: value, + end_value: value, + unit: session::ParameterUnit::Db, + }], + }]; + (label, automated, reference) + }) + .collect() +} + +/// Boots `model` on a live-controlled browser host and renders `blocks` blocks of +/// [`strip_planes`], returning each block's output bits. +fn render_flat_fader_web(model: &session::SessionModel, blocks: u64) -> Vec> { + let document = canonical_session_json(model).expect("canonical"); + let mut host = strip_boot( + &document, + WebBootOptions { + require_sample_rate_hz: 48_000, + require_quantum_frames: STRIP_QUANTUM, + source_ring_frames: STRIP_QUANTUM * 4, + ..WebBootOptions::live_control_defaults() + }, + ); + (0..blocks) + .map(|block| { + submit_strip_source(&mut host, "fixture-source", block, &strip_planes(0, block)); + assert_eq!(host.render_next(), RESULT_OK, "block {block}"); + host.output_pcm() + .expect("output") + .iter() + .map(|sample| sample.to_bits()) + .collect() + }) + .collect() +} + +/// #1646 gate 1 (browser): a flat stored fader automation renders, bit for bit, the static fader +/// at its value for 64 blocks on a live-controlled browser host, in the C ABI's four cases (no +/// VCA, one `+3` dB VCA, the crafted three-VCA offsets, `-0.0` dB on a lane no VCA reaches), and +/// each automated session renders differently without its automation. +/// +/// Test value: red if the browser's preparation composes the offsets in another order than the +/// static path, if the `+0.0` of a lane no VCA reaches changes the gain bits, if the prepared value +/// is not the curve's, or if the browser converts dB differently from the static fader. +#[test] +fn a_flat_fader_automation_renders_the_static_fader_bits_in_the_browser() { + const BLOCKS: u64 = 64; + for (label, automated, reference) in flat_fader_sessions() { + let rendered = render_flat_fader_web(&automated, BLOCKS); + let expected = render_flat_fader_web(&reference, BLOCKS); + let mut unautomated = automated.clone(); + unautomated.automation.clear(); + let without = render_flat_fader_web(&unautomated, BLOCKS); + assert!( + expected + .iter() + .flatten() + .any(|bits| f32::from_bits(*bits) != 0.0), + "{label}: the reference plays" + ); + assert_ne!(without, expected, "{label}: the automation is heard"); + for (block, (rendered, expected)) in rendered.iter().zip(&expected).enumerate() { + assert_eq!( + rendered, expected, + "{label}: block {block} renders the static fader's bits" + ); + } + } +} From 6a9a82e1e8a550eb80f89c5dd55b3d693e6f7f2d Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 18:05:57 +0000 Subject: [PATCH 06/35] Give #1646b's test automation entries the #1650a offset Merge fallout: three struct literals in the #1646b gates gain the required offset record (+0.0 on both lanes). Co-Authored-By: Claude Opus 5.5 --- crates/capi/src/runtime/tests.rs | 1 + crates/host-core/tests/stored_fader_automation.rs | 1 + hosts/host-web/src/tests.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/capi/src/runtime/tests.rs b/crates/capi/src/runtime/tests.rs index b65fcde6b..7d2c59ee3 100644 --- a/crates/capi/src/runtime/tests.rs +++ b/crates/capi/src/runtime/tests.rs @@ -6333,6 +6333,7 @@ fn flat_fader_entry( end_value: value, unit: session::ParameterUnit::Db, }], + offset: session::AutomationOffset::default(), } } diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index adf53e715..cd10d5dcb 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -82,6 +82,7 @@ fn flat_fader( end_value: value, unit: ParameterUnit::Db, }], + offset: session::AutomationOffset::default(), } } diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 18bb6acfe..17e5418a8 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -10092,6 +10092,7 @@ fn flat_fader_sessions() -> Vec<(&'static str, session::SessionModel, session::S end_value: value, unit: session::ParameterUnit::Db, }], + offset: session::AutomationOffset::default(), }]; (label, automated, reference) }) From b844501830441c3b7ed4fee0bca4f7e8957c37b9 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 18:18:33 +0000 Subject: [PATCH 07/35] Refuse automation samples at or past 2^53 in the session validator The validator now refuses the same segment samples as automation::ProgramBuilder (shared automation::TIME_LIMIT; every entry, rendered row or not), with numeric.out_of_schema_range. Before, a session the validator accepted could be refused at preparation with host.automation.fader (U1 review MINOR 3, root). The SDK builder mirrors it. Tests that used u64::MAX as a valid sample use 2^53 - 1. session depends on automation (a leaf); fuzz lockfile updated. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/author-session/SKILL.md | 1 + .../1646-prepare-stored-fader-automation.md | 5 ++ Cargo.lock | 1 + crates/automation/src/lib.rs | 1 + crates/automation/src/program.rs | 7 +- crates/session/Cargo.toml | 2 + crates/session/src/validate.rs | 16 +++++ crates/session/tests/automation_lanes.rs | 30 ++++++++ crates/session/tests/diagnostic_parity.rs | 71 +++++++++++++++++++ crates/session/tests/invalid_matrix.rs | 19 +++-- crates/session/tests/json_grammar.rs | 31 ++++++-- docs/SESSION_SCHEMA_V1.md | 6 +- fuzz/Cargo.lock | 1 + sdk/src/core/session.ts | 12 ++++ sdk/test/builder-evals.mjs | 25 ++++++- sdk/test/enginectl-cli.mjs | 4 +- 16 files changed, 211 insertions(+), 21 deletions(-) diff --git a/.claude/skills/author-session/SKILL.md b/.claude/skills/author-session/SKILL.md index 39d1c4581..c0bdde35e 100644 --- a/.claude/skills/author-session/SKILL.md +++ b/.claude/skills/author-session/SKILL.md @@ -165,6 +165,7 @@ is `schema.unknown_field`; out of range is `numeric.out_of_schema_range` at | A second entry on a lane an entry with a lesser `id` drives (`both` overlaps `left` and `right`), at `$.automation[].target.lane` | `automation.duplicate_lane` | typed-model | | Two discontinuities of one entry less than 64 samples apart, at the later one's `start_sample` or `end_sample` | `automation.discontinuity_spacing` | typed-model | | A repeated entry `id` | `id.duplicate` | typed-model | +| An automation segment `start_sample` or `end_sample` at or past 2^53 (`automation::TIME_LIMIT`, the stored-automation program's time bound), on any row, at that sample's path | `numeric.out_of_schema_range` | typed-model | | Segments out of order, overlapping or empty | `automation.out_of_order`, `automation.segment_overlap`, `automation.invalid_range` | typed-model | | A segment whose unit is not the row's, or a value the row refuses (a fader above 24 dB, a filter above the rate's maximum), at `$.automation[id=].segments[]` | `control.domain` | prepare-effects | | A `linear` or `exponential` segment on a toggle (polarity, mute) or enumeration row; `exponential` on a dB row or a row whose minimum is not above 0; a `linear` or `exponential` filter segment from or to `0` ("off" is reached only by a `step`) | `control.automation.shape` | prepare-effects | diff --git a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md index d2bf12932..41b952f34 100644 --- a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md @@ -542,6 +542,11 @@ grows by 16 bytes per fader bank, a fixed structural change inside every ceiling segment end at or past `2^53` samples, which the session validator accepts and `ProgramBuilder` refuses (`BuildError::Segment`), so that refusal is reachable, not only the unreachable conversion of D2. + *Batch 17 follow-up:* the session validator now refuses a segment `start_sample` or + `end_sample` at or past `automation::TIME_LIMIT` (`2^53`) with `numeric.out_of_schema_range`, so a + validated session no longer reaches this builder refusal; it stays as a defensive check. + Gate: `the_validator_and_the_program_builder_agree_on_the_segment_time_bound` + (`crates/session/tests/diagnostic_parity.rs`). 3. **No offsets-cell writer here.** #1649 departure 4 adds the writer to `FaderCells`; the cell is created unwritten and its holder keeps the prepared words. 4. **Parts with no reader until the same push** carry `#[expect(dead_code, reason = ...)]`, which diff --git a/Cargo.lock b/Cargo.lock index 9fef48ecc..786acdeb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1932,6 +1932,7 @@ dependencies = [ name = "session" version = "0.1.0" dependencies = [ + "automation", "engine", "json-syntax", "jsonschema", diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 61e0f5731..6a6e9178e 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -52,6 +52,7 @@ pub use events::{ }; pub use program::{ BuildError, CellDescription, CellProgram, DISCONTINUITY_SPACING, Program, ProgramBuilder, + TIME_LIMIT, }; /// The shape of one segment. diff --git a/crates/automation/src/program.rs b/crates/automation/src/program.rs index b28ede51e..d1b35a60c 100644 --- a/crates/automation/src/program.rs +++ b/crates/automation/src/program.rs @@ -5,8 +5,11 @@ use alloc::vec::Vec; use crate::{Completion, JumpLength, Segment, Shape, value_at}; -/// Times are exact in `f64` below this bound, which the evaluator relies on. -const TIME_LIMIT: u64 = 1 << 53; +/// Times are exact in `f64` below this bound, which the evaluator relies on: a segment's start and +/// end sample are both below it, so the largest accepted sample is `TIME_LIMIT - 1`. The session +/// validator refuses a segment at or past it with the same constant (`numeric.out_of_schema_range` +/// at the sample's path), so a validated session never reaches [`BuildError::Segment`] on it. +pub const TIME_LIMIT: u64 = 1 << 53; /// The least distance, in node-time samples, between two discontinuities of one cell: the session /// refuses closer ones (`automation.discontinuity_spacing`, #1626 D3), and the builder refuses diff --git a/crates/session/Cargo.toml b/crates/session/Cargo.toml index 256a57240..d7ffd8ea0 100644 --- a/crates/session/Cargo.toml +++ b/crates/session/Cargo.toml @@ -13,6 +13,8 @@ name = "session" default = [] [dependencies] +# The segment time bound: one constant, `automation::TIME_LIMIT`, for the validator and the builder. +automation.workspace = true engine.workspace = true json-syntax = { version = "=0.12.5", default-features = false } diff --git a/crates/session/src/validate.rs b/crates/session/src/validate.rs index 836e7d4ba..1d668dfc8 100644 --- a/crates/session/src/validate.rs +++ b/crates/session/src/validate.rs @@ -1003,6 +1003,22 @@ fn validate_automation( "end_sample must be greater than start_sample", ); } + // The program builder refuses a segment whose start or end is at or past `2^53` (times + // are exact in `f64` below it), so the validator refuses the same set, each sample at + // its own path. + for (field, sample) in [ + ("start_sample", segment.start_sample), + ("end_sample", segment.end_sample), + ] { + if sample >= automation::TIME_LIMIT { + error( + diagnostics, + DiagnosticCode::NumericOutOfSchemaRange, + &segment_path.key(field), + "a segment sample must be below 2^53 (9007199254740992)", + ); + } + } if previous_start.is_some_and(|start| segment.start_sample < start) { ordered = false; error( diff --git a/crates/session/tests/automation_lanes.rs b/crates/session/tests/automation_lanes.rs index 173bad39e..ca205430f 100644 --- a/crates/session/tests/automation_lanes.rs +++ b/crates/session/tests/automation_lanes.rs @@ -255,3 +255,33 @@ fn spacing_never_reports_on_a_malformed_sequence() { )] ); } + +/// The segment time bound (`automation::TIME_LIMIT`, `2^53`) is the validator's whatever the row: +/// an insert row (not rendered today) and the fader row (rendered, #1646) are refused the same way +/// at the same path, and `2^53 - 1` is accepted on both. Red if the bound is made to depend on the +/// row, which would let a validated fader session reach the program builder's refusal. +#[test] +fn the_segment_time_bound_does_not_depend_on_the_row() { + let limit = automation::TIME_LIMIT; + let end_path = "$.automation[0].segments[0].end_sample".to_owned(); + let models: [fn(Vec) -> SessionModel; 2] = [with_segments, |segments| { + let mut model = fader_rides(&[("ride", ParameterChannel::Both)]); + model.automation[0].segments = segments; + model + }]; + for build in models { + let accepted = build(vec![segment( + AutomationShape::Step, + limit - 2, + limit - 1, + 0.0, + 0.0, + )]); + assert_eq!(refusals(&accepted), Vec::new()); + let refused = build(vec![segment(AutomationShape::Step, 0, limit, 0.0, 0.0)]); + assert_eq!( + refusals(&refused), + vec![(DiagnosticCode::NumericOutOfSchemaRange, end_path.clone())] + ); + } +} diff --git a/crates/session/tests/diagnostic_parity.rs b/crates/session/tests/diagnostic_parity.rs index b686036ff..f851514cf 100644 --- a/crates/session/tests/diagnostic_parity.rs +++ b/crates/session/tests/diagnostic_parity.rs @@ -816,6 +816,26 @@ fn parse_canonical_and_compile_diagnostics_have_code_path_and_span_parity() { p.end_value = 2.5 } ), + case!( + "automation-end-at-time-limit", + NumericOutOfSchemaRange, + "$.automation[0].segments[0].end_sample", + |s| { + let p = &mut s.automation[0].segments[0]; + p.start_sample = 0; + p.end_sample = automation::TIME_LIMIT + } + ), + case!( + "automation-start-at-time-limit", + NumericOutOfSchemaRange, + "$.automation[0].segments[0].start_sample", + |s| { + let p = &mut s.automation[0].segments[0]; + p.start_sample = automation::TIME_LIMIT; + p.end_sample = automation::TIME_LIMIT + 1 + } + ), ]; assert!( cases.len() >= 70, @@ -908,3 +928,54 @@ fn revision_above_the_plan_gate_ceiling_is_refused_by_every_entry_point() { ); } } + +/// The session validator and `automation::ProgramBuilder` agree on which segment samples are +/// allowed (#1646b departure 2): a segment the validator accepts never reaches +/// `BuildError::Segment` at preparation, and one the builder refuses for its time is refused by +/// the validator. Red if either side moves its bound (`2^53`) alone. +#[test] +fn the_validator_and_the_program_builder_agree_on_the_segment_time_bound() { + use automation::{ + BuildError, CellDescription, Completion, JumpLength, ProgramBuilder, Segment, Shape, + TIME_LIMIT, + }; + assert_eq!(TIME_LIMIT, 1 << 53, "the bound is exact in f64"); + let edges = [ + (0, 1, true), + (TIME_LIMIT - 2, TIME_LIMIT - 1, true), + (0, TIME_LIMIT - 1, true), + (0, TIME_LIMIT, false), + (TIME_LIMIT - 1, TIME_LIMIT, false), + (TIME_LIMIT, TIME_LIMIT + 1, false), + (0, u64::MAX, false), + (u64::MAX - 1, u64::MAX, false), + ]; + for (start, end, accepted) in edges { + let mut model = parse_session_json(CANONICAL).expect("fixture parses"); + let segment = &mut model.automation[0].segments[0]; + segment.shape = AutomationShape::Step; + segment.start_sample = start; + segment.end_sample = end; + let validator_accepts = canonical_session_json(&model).is_ok(); + let segments = [Segment { + start, + end, + start_value: 0.0, + end_value: 0.0, + shape: Shape::Step, + }]; + let builder_accepts = !matches!( + ProgramBuilder::build(&[CellDescription { + segments: &segments, + grid_ramp: 64, + grid: 64, + jump: JumpLength::Block, + completion: Completion::End, + no_restart: false, + }]), + Err(BuildError::Segment { .. }) + ); + assert_eq!(validator_accepts, accepted, "validator at {start}..{end}"); + assert_eq!(builder_accepts, accepted, "builder at {start}..{end}"); + } +} diff --git a/crates/session/tests/invalid_matrix.rs b/crates/session/tests/invalid_matrix.rs index af7f907b9..72dfc4f3f 100644 --- a/crates/session/tests/invalid_matrix.rs +++ b/crates/session/tests/invalid_matrix.rs @@ -56,27 +56,32 @@ fn model_case( #[test] fn u64_model_fields_accept_the_full_unsigned_domain() { // `revision` is not a full-domain leaf: it is bounded at `MAX_SESSION_REVISION` (#1503) and - // its refusal is pinned in `diagnostic_parity.rs`. + // its refusal is pinned in `diagnostic_parity.rs`. Nor is an automation segment sample: it is + // bounded below `automation::TIME_LIMIT`, `2^53` (pinned there too); its largest value is + // checked here. type Mutation = fn(&mut SessionModel); - let cases: &[(Mutation, &str)] = &[ + let largest_sample = automation::TIME_LIMIT - 1; + let cases: &[(Mutation, &str, u64)] = &[ ( |session| session.sources[0].frames = u64::MAX, "$.sources[0].frames", + u64::MAX, ), ( |session| { - session.automation[0].segments[0].start_sample = u64::MAX - 1; - session.automation[0].segments[0].end_sample = u64::MAX; + session.automation[0].segments[0].start_sample = automation::TIME_LIMIT - 2; + session.automation[0].segments[0].end_sample = automation::TIME_LIMIT - 1; }, "$.automation[0].segments[0].end_sample", + largest_sample, ), ]; - for (mutate, path) in cases { + for (mutate, path, largest) in cases { let mut session = parse_session_json(EXAMPLE).expect("fixture parses"); mutate(&mut session); - let canonical = canonical_session_json(&session).expect("full u64 domain canonicalizes"); - assert!(canonical.contains(&format!("\"{}\"", u64::MAX)), "{path}"); + let canonical = canonical_session_json(&session).expect("full domain canonicalizes"); + assert!(canonical.contains(&format!("\"{largest}\"")), "{path}"); } } diff --git a/crates/session/tests/json_grammar.rs b/crates/session/tests/json_grammar.rs index ee96ca1b8..db8f94903 100644 --- a/crates/session/tests/json_grammar.rs +++ b/crates/session/tests/json_grammar.rs @@ -182,22 +182,39 @@ fn every_u64_leaf_accepts_the_full_unsigned_domain() { let canonical = canonical_session_json(&model).expect("full-domain u64 leaf writes"); assert!(canonical.contains(replacement)); - // The semantic interval rule requires start < end, so exercise both leaves at their highest - // jointly valid values rather than weakening validation merely to test lexical width. + // The semantic interval rule requires start < end, and an automation sample is bounded below + // `automation::TIME_LIMIT` (2^53), so exercise both leaves at their highest jointly valid + // values rather than weakening validation merely to test lexical width. + let high = automation::TIME_LIMIT - 1; let source = CANONICAL .replacen( "\"start_sample\": \"0\"", - "\"start_sample\": \"18446744073709551614\"", + &format!("\"start_sample\": \"{}\"", high - 1), 1, ) .replacen( "\"end_sample\": \"480\"", - "\"end_sample\": \"18446744073709551615\"", + &format!("\"end_sample\": \"{high}\""), 1, ); - let model = parse_session_json(&source).expect("full-domain automation interval parses"); - assert_eq!(model.automation[0].segments[0].start_sample, u64::MAX - 1); - assert_eq!(model.automation[0].segments[0].end_sample, u64::MAX); + let model = parse_session_json(&source).expect("highest automation interval parses"); + assert_eq!(model.automation[0].segments[0].start_sample, high - 1); + assert_eq!(model.automation[0].segments[0].end_sample, high); + + // The lexical width is still the full u64; the semantic bound refuses the rest. + let source = CANONICAL.replacen( + "\"end_sample\": \"480\"", + "\"end_sample\": \"18446744073709551615\"", + 1, + ); + let errors = parse_session_json(&source).expect_err("an end sample past 2^53 is refused"); + assert!( + errors.diagnostics().iter().any(|item| { + item.code == session::DiagnosticCode::NumericOutOfSchemaRange + && item.path.to_string() == "$.automation[0].segments[0].end_sample" + }), + "{errors}" + ); } #[test] diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index 5eff0c047..5f68bf246 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -26,7 +26,11 @@ Solidus and all other Unicode scalars, including U+2028, U+2029 and non-BMP scal Booleans and `u8`/`u32` fields are JSON booleans/numbers. Integer-number fields reject fractions, exponents and any leading minus, including `-0`. Every typed `u64` leaf (source `frames`, and automation `start_sample`/`end_sample`) is a canonical unsigned decimal JSON string matching -`^(0|[1-9][0-9]*)$`, bounded through `18446744073709551615`. `revision` is the same canonical +`^(0|[1-9][0-9]*)$`, bounded through `18446744073709551615`; an automation segment's +`start_sample` and `end_sample` are further bounded below `2^53` (`9007199254740992`, +`automation::TIME_LIMIT`: node times are exact in `f64` below it, and the stored-automation program +builder refuses a segment at or past it), so the validator refuses a larger one with +`numeric.out_of_schema_range` at that sample's path. `revision` is the same canonical decimal string, bounded through `9223372036854775807` (2^63 - 1, `session::MAX_SESSION_REVISION`): a plan's revision gate cannot hold a larger revision, so the validator refuses one with `numeric.out_of_schema_range` at `$.revision`. Finite `f32` fields accept diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 3b54bca50..e062aa787 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -557,6 +557,7 @@ checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" name = "session" version = "0.1.0" dependencies = [ + "automation", "engine", "json-syntax", ] diff --git a/sdk/src/core/session.ts b/sdk/src/core/session.ts index f0dc66053..276118b40 100644 --- a/sdk/src/core/session.ts +++ b/sdk/src/core/session.ts @@ -157,6 +157,9 @@ const LAUNCH_RATES: readonly number[] = [44_100, 48_000, 88_200, 96_000]; const U64_MAX = 18_446_744_073_709_551_615n; /** The session schema's `revision` ceiling, 2^63 - 1: the largest revision a plan's gate holds. */ const REVISION_MAX = 9_223_372_036_854_775_807n; +/** A stored-automation segment's start and end sample stay below 2^53, the engine's + * `automation::TIME_LIMIT`: times are exact in `f64` below it. */ +const AUTOMATION_TIME_LIMIT = 9_007_199_254_740_992n; const SEND_TAPS: ReadonlySet = new Set([ "input", "post_input", @@ -1571,6 +1574,15 @@ function normalizeAutomation(spec: AutomationSpec, state: BuilderState): ModelRe const start = u64(segment.startSample, `${where}.startSample`); const end = u64(segment.endSample, `${where}.endSample`); if (end <= start) fail(`${where}.endSample`, "endSample must be greater than startSample"); + for (const [field, sample] of [["startSample", start], ["endSample", end]] as const) { + if (sample >= AUTOMATION_TIME_LIMIT) { + fail( + `${where}.${field}`, + "a segment sample must be below 2^53 (9007199254740992)", + CODE.outOfRange, + ); + } + } if (previousStart !== undefined && start < previousStart) { fail(`${where}.startSample`, "segments must be declared in nondecreasing start order"); } diff --git a/sdk/test/builder-evals.mjs b/sdk/test/builder-evals.mjs index 928d90769..8bfd1ed40 100644 --- a/sdk/test/builder-evals.mjs +++ b/sdk/test/builder-evals.mjs @@ -852,6 +852,26 @@ describe("validation refusals name the offending path", () => { // What the session document checks, the builder checks at the verb, by path. const refused = (target) => code(() => base.automation(ride("x", target, 0, 1))); + // A segment sample is below 2^53, the program builder's bound (the session validator refuses + // the same set): 2^53 - 1 builds, 2^53 is refused when the document is built, under the + // engine's range code, and the engine refuses it the same way. + const limit = 2n ** 53n; + const rideAt = (startSample, endSample) => base.automation({ + id: "far", + target: builtinRow("fader_db", "both"), + segments: [{ shape: "linear", startSample, endSample, startValue: -2, endValue: 0 }], + }); + assert.equal(rideAt(limit - 2n, limit - 1n).toJSON().automation[0].segments[0].end_sample, String(limit - 1n)); + assert.equal(code(() => rideAt(0n, limit).toJSON()).code, "numeric.out_of_schema_range"); + assert.equal(code(() => rideAt(limit, limit + 1n).toJSON()).code, "numeric.out_of_schema_range"); + if (asset !== undefined) { + const model = mutableModel(rideAt(limit - 2n, limit - 1n).toJSON()); + assert.equal((await validate(JSON.stringify(model), { asset })).ok, true); + model.automation[0].segments[0].end_sample = limit.toString(); + const outcome = await validate(JSON.stringify(model), { asset }); + assert.equal(outcome.ok, false, "the engine must refuse a segment end at 2^53"); + assert.equal(outcome.diagnostics[0]?.code, "numeric.out_of_schema_range"); + } assert.equal(refused({ ...builtinRow("fader_db", "both"), owner: { kind: "strip", id: "ghost" } }).code, "reference.missing_entity"); assert.equal(refused({ ...builtinRow("fader_db", "both"), owner: { kind: "route", id: "ghost" } }).code, "reference.missing_entity"); assert.equal(refused({ ...builtinRow("fader_db", "both"), owner: { kind: "vca", id: "ghost" } }).code, "reference.missing_entity"); @@ -1129,13 +1149,14 @@ describe("canonical float spellings", () => { control: "fader_db", lane: "both", }, - segments: [{ shape: "step", startSample: 0, endSample: maximum, startValue: 0, endValue: 0 }], + // A segment sample is below 2^53, not u64::MAX: the program builder's bound. + segments: [{ shape: "step", startSample: 0, endSample: 2n ** 53n - 1n, startValue: 0, endValue: 0 }], }); const model = built.toJSON(); assert.equal(model.revision, REVISION_MAX.toString()); assert.equal(model.sources[0].frames, maximum.toString()); assert.equal(model.automation[0].segments[0].start_sample, "0"); - assert.equal(model.automation[0].segments[0].end_sample, maximum.toString()); + assert.equal(model.automation[0].segments[0].end_sample, String(2n ** 53n - 1n)); assert.match(built.toJson(), /"revision": "9223372036854775807"/); assert.throws(() => session({ id: "overflow", sampleRateHz: 48_000, revision: maximum + 1n }), /u64::MAX/); assert.throws(() => session({ id: "unsafe", sampleRateHz: 48_000, revision: Number.MAX_SAFE_INTEGER + 1 }), /safe integer/); diff --git a/sdk/test/enginectl-cli.mjs b/sdk/test/enginectl-cli.mjs index b99845b7e..34b1fa43c 100644 --- a/sdk/test/enginectl-cli.mjs +++ b/sdk/test/enginectl-cli.mjs @@ -190,13 +190,13 @@ describe("enginectl session build", () => { maximum.session.revision = "9223372036854775807"; maximum.sources[0].spec.frames = "18446744073709551615"; maximum.sources[1].spec.frames = "18446744073709551615"; - maximum.automation[0].segments[0].endSample = "18446744073709551615"; + maximum.automation[0].segments[0].endSample = "9007199254740991"; const accepted = await run(["session", "build", "--request", "-", "--output", "-"], JSON.stringify(maximum)); assert.equal(accepted.status, 0, accepted.stderr.toString("utf8")); const document = JSON.parse(accepted.stdout.toString("utf8")); assert.equal(document.revision, "9223372036854775807"); assert.equal(document.sources[0].frames, "18446744073709551615"); - assert.equal(document.automation[0].segments[0].end_sample, "18446744073709551615"); + assert.equal(document.automation[0].segments[0].end_sample, "9007199254740991"); }); test("retired --stems is unknown and publishes no output", async () => { From 4667ff6189bf84d794b70aa6c080345268b358b0 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 18:27:48 +0000 Subject: [PATCH 08/35] Give the fader bank its input arrivals and per-block node time (#1647a) R17-2: at the end of compile_with_builtins the graph compiler installs each builtin bank's floored input arrivals through a defaulted processor method. R17-3: before chain.run the graph sets each builtin slot's block time (the timeline span at r + sum P - a(n), the held jump lengths) through builtin_processor_mut; no rack edit, allocation-free, O(slots). R17-5: the source timeline reader takes a signed first sample (i128, checked, saturating at the limits). LiveRamps::from_lengths in controls. Realtime policy 153 -> 155 regions. audit capi pcm_digest unchanged. Co-Authored-By: Claude Opus 5.5 --- ...7-render-moving-stored-fader-automation.md | 81 +++++++++ crates/builtins-compiler/src/lib.rs | 168 +++++++++++++++++- crates/controls/src/live_ramps.rs | 17 +- crates/graph-compiler/src/compile.rs | 8 +- crates/graph/src/lib.rs | 162 ++++++++++++++++- crates/graph/src/runtime.rs | 31 +++- .../tests/stored_fader_automation.rs | 159 ++++++++++++++++- crates/source/src/lib.rs | 142 ++++++++++----- 8 files changed, 702 insertions(+), 66 deletions(-) diff --git a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md index ca348dce0..de14e6bcd 100644 --- a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md @@ -416,3 +416,84 @@ The signed form of `TimelineConsumer::timeline_block` and of the driver's `timel a first sample above `i64::MAX`, at `i64::MAX`, at `0` and at `i64::MIN`, and a block that crosses each of them. It checks that nothing panics and nothing wraps. - This is part of #1647a. + +## Decision record: #1647a (batch 17) + +The plumbing (R17-2, R17-3, R17-5, departure 7). No audible change: nothing reads the new values +in render yet except the test-support probe. + +**Shipped.** + +- **`a(n)` (R17-2).** `GraphPreparedBuiltinBankProcessor::set_input_arrivals(&[u64])` (defaulted, + lane order). `PreparedGraphPlan::install_builtin_input_arrivals` looks each bank member up in + `GraphCompileReport::input_arrivals` (absent = 0) and calls it; `compile_with_builtins` calls it + once through `PreparedBuiltinsGraphArtifact::install_builtin_input_arrivals` (a narrow, + doc-hidden forward; the artifact's graph stays read-only). No downcast, no host-core step. The + fader bank keeps `a(n)` in each automated lane's `LaneFaderAutomation`; the fused fader-matrix + bank forwards to its fader stage. +- **Block context (R17-3).** `GraphBlockTime<'a>` (graph): the block's source-read sample + `r + ΣP` (`Option`, `None` only on `u64` overflow, which a plan with a source set already refuses + as `TimeOverflow`), the block length, the held `GraphJumpLengths` (#1639) and a borrow of the + source-set driver; `node_span(a)` reads `timeline_block(r + ΣP - a, frames)`. + `GraphPreparedBuiltinBankProcessor::set_block_time(&GraphBlockTime)` (defaulted). The executor + builds the context once per block after the source set's block entry and passes it to + `Runtime::execute`, which calls `set_block_time` on every builtin slot of a bank unit + (`builtin_processor_mut`, a `slot_stage_mut` walk) before `chain.run` or + `run_with_resident_input`. No `rack` edit. The fader bank, when it has automated lanes, stores per + automated lane the block's `TimelineSpan` and the fader row's jump length; a bank without + automation returns at once. +- **Signed reader (R17-5).** `TimelineConsumer::timeline_block(first: i128, frames)` and the + driver trait's `timeline_block(i128, u32) -> Option` (source driver implements + it). `i128` holds every `u64 - u64` exactly, so `r + ΣP - a(n)` needs no conversion; `resolve` + works in `i128` with checked differences, `offset_timeline` saturates in `i128` then to the + `i64` range. `step_at` keeps `u64` (steps only fall at entry boundaries, which are `u64`), so + every existing `u64` result is unchanged. `TimelineSpan`/`TimelineStep` now live in `graph` and + `source` re-exports them (one type across the driver trait). +- **Jump length (departure 7).** `controls::LiveRamps::from_lengths(fader, mute, pan)` (link = + fader); `for_session` now goes through it. The fader bank resolves its row with + `for_row(LiveRampRow::Fader)` once per block. +- **`expect(dead_code)`.** Removed from `FaderBankProcessor::automation` (now read by + `set_block_time` and `set_input_arrivals`). The ones on `LaneFaderAutomation`, + `AutomatedFaderCell` and `OffsetsLane` stay (their program/state/offsets readers are #1647b and + #1649). + +**Departure.** The per-block span and jump length are stored per automated lane inside +`LaneFaderAutomation`, not as a fixed per-bank array: a per-bank array grew every fader bank by +320 bytes and moved the graph fixture `direct-route.resources.json` (a session with no +automation). Now only automated sessions pay: `lane_slot_bytes` 120 -> 168 (x86-64), measured by +`fader_automation_sizes()` and charged by #1646's existing sum; no fixture moved. + +**Gates.** + +- Probe and allocation (gate 6's counter on the context-set path): + `each_automated_fader_lane_takes_its_arrival_and_node_time_per_block` + (`crates/host-core/tests/stored_fader_automation.rs`): two automated lanes, one behind a 5 ms + true-peak limiter (`a(n)` = 486 from the plan inventory), `fader_ms` 20 (960 samples) apart from + `mute_ms`/`pan_ms`; for 12 blocks each lane holds its `a(n)`, span `start = r - a(n)` (`-486` at + block 0) with no step, and jump 960; blocks 2-11 allocate, reallocate and free 0. Red on the + revert of the compile-end install (`[0, 0]` against `[0, 486]`), checked once. +- R17-5: `tests::timeline::the_signed_reader_extrapolates_back_and_saturates_at_the_limits` + (`crates/source/src/lib.rs`): first samples `i128::MIN`, below/at `i64::MIN`, `-300`, `-40` + (crossing 0, no step, values continuous), 0, `i64::MAX - 40`, `i64::MAX`, above it, `u64::MAX`, + `i128::MAX`, and a history at the `u64` end of the clock with blocks crossing it. +- `cargo test --locked -p source -p graph -p graph-compiler -p builtins-compiler -p controls + --features builtins-compiler/test-support,graph/test-support`: pass. `-p host-core --all-features + --test stored_fader_automation`: 3 pass. `-p capi`: 198 pass. +- `realtime-policy`: ok, 155 regions in 33 files (base 153: `GraphBlockTime`'s impl and the fader + bank's `set_block_time`). +- `./target/release/audit capi`: `pcm_digest` `bffc21e857b5e134` (unchanged), 0 violations, 0 + allocations. +- fmt; clippy `-D warnings` (touched crates, all targets, all features); no-feature + `cargo check --all-targets` on the touched crates; `cargo check --workspace --all-targets + --all-features`. + +**Notes for #1647b.** + +- Read `LaneFaderAutomation::{span, fader_jump}` in `process` (set before `begin_block` of the same + block); build `NodeSpan` from `span` and add the restart `Set` step of departure 4 there. + `span.start + i` must be checked: it saturates at the `i64` edges. +- The scalar oracle (R17-4) gets neither `a(n)` nor the block context: its fader owners are bound + node processors run by `execute_op`, not bank slots. #1647b needs a matching path there (the + bound-node processor trait and the graph-compiler install, or a per-node arrival at binding). +- A plan with no source set or no jump-length cell gives `span`/`fader_jump` `None`; every + host-core plan has both. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index a2bcff09c..0b3644f12 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -41,10 +41,11 @@ use engine::realtime::{ }; use graph::{ BuiltinControlDelivery, BuiltinPairFactory, BuiltinProcessor, BuiltinStripOwner, - BuiltinStripStage, DependencyLevel, GraphBuiltinBankResourceEstimate, GraphNodeId, - GraphNodeObserverBinding, GraphObservationBlock, GraphPreparedBuiltinBank, + BuiltinStripStage, DependencyLevel, GraphBlockTime, GraphBuiltinBankResourceEstimate, + GraphNodeId, GraphNodeObserverBinding, GraphObservationBlock, GraphPreparedBuiltinBank, GraphPreparedBuiltinBankInfo, GraphPreparedBuiltinBankProcessor, GraphPreparedSourceSet, - GraphRuntimeBindings, GraphRuntimeObserver, PreparedGraphPlan, StableGraphId, TrackStage, + GraphRuntimeBindings, GraphRuntimeObserver, PreparedGraphPlan, StableGraphId, TimelineSpan, + TrackStage, }; use lane::Backend; use rack::{AoSoaScratch, BankSlotKey, RackLocation, RackProgram}; @@ -664,6 +665,16 @@ struct LaneFaderAutomation { program: Arc, /// `[left, right]`; `None` for a channel no entry drives. channels: [Option; 2], + /// The lane's floored input arrival `a(n)` (#1647a, R17-2): the graph compiler's, given to a + /// bank lane at the end of `compile_with_builtins`; `0` until then. + arrival: u64, + /// The node-time mapping of the block the graph last set (#1647a, R17-3): + /// `timeline_block(r + ΣP - a(n))`, `None` in a plan with no timeline. #1647b builds the + /// lane's `NodeSpan` from it. + span: Option, + /// The fader row's jump length of that block, from the three lengths the graph holds (#1639 + /// D2); `None` in a plan with no jump-length cell. #1647b passes it to `CellEvents::new`. + fader_jump: Option, } impl LaneFaderAutomation { @@ -688,6 +699,9 @@ impl LaneFaderAutomation { } }) }), + arrival: 0, + span: None, + fader_jump: None, } } } @@ -1793,17 +1807,103 @@ struct FaderBankProcessor { /// The stored fader automation of the bank's lanes (#1646 D1): one slot per bank lane when a /// member's fader is automated, `None` on a lane that is not; empty (no allocation) when no /// member is. - #[expect( - dead_code, - reason = "#1647 drives it in render and #1648 carries it in `adopt_lane`, in the same push \ - (batch R1)" - )] automation: Box<[Option]>, process_calls: u64, frames_processed: u64, control_delivery: BuiltinControlDelivery, } +/// Test-only: one automated fader lane's node time for one block, as the bank took it (#1647a). +#[cfg(any(test, feature = "test-support"))] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[doc(hidden)] +pub struct TestOnlyFaderLaneTime { + /// The bank lane. + pub lane: usize, + /// The lane's `a(n)`. + pub arrival: u64, + /// The block's node-time mapping, `None` with no timeline. + pub span: Option, + /// The fader row's jump length, `None` with no jump-length cell. + pub fader_jump: Option, +} + +#[cfg(any(test, feature = "test-support"))] +const FADER_LANE_TIME_CAPACITY: usize = 16; + +/// Test-only: the automated fader lanes' node times the banks took since the last take, in the +/// order they took them, and whether more than the capacity arrived. +#[cfg(any(test, feature = "test-support"))] +#[derive(Clone, Copy)] +struct TestOnlyFaderLaneTimes { + len: usize, + overflow: bool, + entries: [TestOnlyFaderLaneTime; FADER_LANE_TIME_CAPACITY], +} + +#[cfg(any(test, feature = "test-support"))] +thread_local! { + static FADER_LANE_TIMES: core::cell::Cell = + const { + core::cell::Cell::new(TestOnlyFaderLaneTimes { + len: 0, + overflow: false, + entries: [TestOnlyFaderLaneTime { + lane: 0, + arrival: 0, + span: None, + fader_jump: None, + }; FADER_LANE_TIME_CAPACITY], + }) + }; +} + +/// Append each automated lane's node time to this thread's probe. Allocation-free: a copy in and +/// out of a fixed array. +#[cfg(any(test, feature = "test-support"))] +fn test_only_record_fader_lane_times(automation: &[Option]) { + FADER_LANE_TIMES.with(|cell| { + let mut times = cell.get(); + for (lane, slot) in automation.iter().enumerate() { + let Some(slot) = slot else { + continue; + }; + let entry = TestOnlyFaderLaneTime { + lane, + arrival: slot.arrival, + span: slot.span, + fader_jump: slot.fader_jump, + }; + match times.entries.get_mut(times.len) { + Some(free) => { + *free = entry; + times.len += 1; + } + None => times.overflow = true, + } + } + cell.set(times); + }); +} + +/// Test-only (#1647a): take the automated fader lanes' node times this thread's banks took since +/// the last take, in order, and whether the probe's capacity overflowed. Each block's +/// `set_block_time` adds one entry per automated lane of every fader bank. +#[cfg(any(test, feature = "test-support"))] +#[doc(hidden)] +#[must_use] +pub fn test_only_take_fader_lane_times() -> (Vec, bool) { + FADER_LANE_TIMES.with(|cell| { + let times = cell.get(); + cell.set(TestOnlyFaderLaneTimes { + len: 0, + overflow: false, + ..times + }); + (times.entries[..times.len].to_vec(), times.overflow) + }) +} + impl FaderBankProcessor { /// The block drain under `snapshot`: the body of `begin_block`. fn drain(&mut self, snapshot: LiveSnapshot) -> Result<(), RenderError> { @@ -1866,6 +1966,40 @@ impl GraphPreparedBuiltinBankProcessor for FaderBankProcessor { fn control_delivery(&self) -> BuiltinControlDelivery { self.control_delivery } + /// #1647a (R17-2): each automated lane keeps its member's `a(n)`. + fn set_input_arrivals(&mut self, arrivals: &[u64]) { + for (slot, &arrival) in self.automation.iter_mut().zip(arrivals) { + if let Some(slot) = slot { + slot.arrival = arrival; + } + } + } + // REALTIME_POLICY_BEGIN + /// #1647a (R17-3): each automated lane takes the block's node-time mapping + /// `timeline_block(r + ΣP - a(n))` and the fader row's jump length, resolved once from the + /// three lengths the graph holds through `LiveRamps`, which owns the row rule. A bank with no + /// automated lane keeps nothing and reads no timeline. Allocation-free; one timeline read per + /// automated lane. + fn set_block_time(&mut self, time: &GraphBlockTime<'_>) { + if self.automation.is_empty() { + return; + } + let fader_jump = time.jump_lengths().map(|lengths| { + controls::LiveRamps::from_lengths( + lengths.fader_samples, + lengths.mute_samples, + lengths.pan_samples, + ) + .for_row(effect_contract::control::LiveRampRow::Fader) + }); + for slot in self.automation.iter_mut().flatten() { + slot.span = time.node_span(slot.arrival); + slot.fader_jump = fader_jump; + } + #[cfg(any(test, feature = "test-support"))] + test_only_record_fader_lane_times(&self.automation); + } + // REALTIME_POLICY_END // REALTIME_POLICY_BEGIN fn carried_strip_lanes(&self) -> Option<(BuiltinStripStage, usize)> { Some((BuiltinStripStage::Fader, self.bank.active_lanes())) @@ -2529,7 +2663,14 @@ impl GraphPreparedBuiltinBankProcessor for FaderMatrixBankProcessor { fn control_delivery(&self) -> BuiltinControlDelivery { BuiltinControlDelivery::BetweenRenderCalls } + fn set_input_arrivals(&mut self, arrivals: &[u64]) { + self.fader.set_input_arrivals(arrivals); + } // REALTIME_POLICY_BEGIN + /// The fader stage's context (#1647a); the matrix stage takes none. + fn set_block_time(&mut self, time: &GraphBlockTime<'_>) { + self.fader.set_block_time(time); + } fn carried_strip_lanes(&self) -> Option<(BuiltinStripStage, usize)> { Some(( BuiltinStripStage::FaderMatrix, @@ -4517,6 +4658,17 @@ fn restore_graph_bind_failure( } impl PreparedBuiltinsGraphArtifact { + /// Give each builtin bank its members' floored input arrivals `a(n)` (#1647a, R17-2), from + /// `GraphCompileReport::input_arrivals`. The graph compiler's one call at the end of + /// `compile_with_builtins`; it changes nothing but what the banks hold of `a(n)`. Off the + /// render thread. + #[doc(hidden)] + pub fn install_builtin_input_arrivals( + &mut self, + arrivals: &[(GraphNodeId, effect_contract::LatencySamples)], + ) { + self.graph.install_builtin_input_arrivals(arrivals); + } /// Immutable caller-owned graph report. #[must_use] /// The sealed graph, by shared reference. diff --git a/crates/controls/src/live_ramps.rs b/crates/controls/src/live_ramps.rs index aa52bd9c3..088c18850 100644 --- a/crates/controls/src/live_ramps.rs +++ b/crates/controls/src/live_ramps.rs @@ -29,11 +29,20 @@ impl LiveRamps { #[must_use] pub fn for_session(model: &SessionModel) -> Self { let samples = model.control_smoothing_samples(); + Self::from_lengths(samples.fader, samples.mute, samples.pan) + } + + /// The ramps of the three session lengths `fader_ms`, `mute_ms` and `pan_ms`, in samples: the + /// one place that derives the rest of the table from them (the link glide reads the fader + /// length). Render builds it from the three words a plan's jump-length cell holds for the + /// block (#1639 D2, #1647 departure 7), so no render path writes a row rule. + #[must_use] + pub const fn from_lengths(fader_samples: u32, mute_samples: u32, pan_samples: u32) -> Self { Self { - fader_samples: samples.fader, - mute_samples: samples.mute, - pan_samples: samples.pan, - link_samples: samples.fader, + fader_samples, + mute_samples, + pan_samples, + link_samples: fader_samples, } } diff --git a/crates/graph-compiler/src/compile.rs b/crates/graph-compiler/src/compile.rs index 27a159c72..96d7f243f 100644 --- a/crates/graph-compiler/src/compile.rs +++ b/crates/graph-compiler/src/compile.rs @@ -112,7 +112,9 @@ impl GraphCompiler { } }; let levels = compiled.graph.dependency_levels.clone(); - Ok(builtins.into_graph_artifact_with_banks( + // #1647a (R17-2): each builtin bank takes its members' floored input arrivals `a(n)`. + let arrivals = compiled.report.input_arrivals.clone(); + let mut artifact = builtins.into_graph_artifact_with_banks( compiled.graph, compiled.report, dispatch, @@ -122,7 +124,9 @@ impl GraphCompiler { // is one derivation and both planners read it, so they cannot form different // opinions about a track and silently decline the strip's chain merges. &compiled.pool_classes, - )) + ); + artifact.install_builtin_input_arrivals(&arrivals); + Ok(artifact) } /// The graph pipeline, run against builtins `compile_with_builtins` has already validated diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 034eca344..9b6df8365 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1267,6 +1267,93 @@ impl GraphJumpLengths { // REALTIME_POLICY_END } +/// The node-time mapping of one node block (#1640 D3): the timeline value at the block's first +/// sample, and the seek that reaches the block, if any. The source crate's timeline reader returns +/// it, and graph hands it to a builtin bank through [`GraphBlockTime::node_span`] (#1647a). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TimelineSpan { + /// The timeline value at the node block's first sample. + pub start: i64, + /// The seek that reaches the node block, if any. + pub step: Option, +} + +/// A seek inside a node block: from sample `offset` of the block on, the timeline reads +/// `timeline + (i - offset)`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TimelineStep { + /// The node block's sample the seek takes effect at, from `0` to the block length minus one. + pub offset: u32, + /// The timeline value at that sample. + pub timeline: i64, +} + +/// One graph block's node-time context (#1647a, R17-3): what a builtin bank needs to map its +/// lanes' samples to session timeline samples for the block. The graph builds it once per block, +/// after the source set's block entry, and sets it on every builtin slot of a bank unit through +/// [`GraphPreparedBuiltinBankProcessor::set_block_time`] before the unit's chain runs. It borrows +/// the source set's driver for the block; a stage reads what it needs during that call and keeps +/// no reference. +#[derive(Clone, Copy)] +pub struct GraphBlockTime<'a> { + source_read_sample: Option, + frames: u32, + jump_lengths: Option, + timeline: Option<&'a dyn GraphPreparedSourceSetDriver>, +} + +// REALTIME_POLICY_BEGIN +impl<'a> GraphBlockTime<'a> { + /// The context of a block of `frames` samples whose first sample reads source time from + /// `source_read_sample` (`r + ΣP`, #1396; `None` when that sum leaves the `u64` range, which a + /// plan with a source set refuses as `TimeOverflow` first), holding `jump_lengths` (#1639 D2), + /// with `timeline` the source set's driver or `None` in a plan with no source set. + #[must_use] + pub const fn new( + source_read_sample: Option, + frames: u32, + jump_lengths: Option, + timeline: Option<&'a dyn GraphPreparedSourceSetDriver>, + ) -> Self { + Self { + source_read_sample, + frames, + jump_lengths, + timeline, + } + } + + /// The source-read sample `r + ΣP` of the block's first sample. + #[must_use] + pub const fn source_read_sample(&self) -> Option { + self.source_read_sample + } + + /// The block length. + #[must_use] + pub const fn frames(&self) -> u32 { + self.frames + } + + /// The three jump lengths the graph holds for the block (#1639 D2), or `None` for a plan with + /// no jump-length cell. + #[must_use] + pub const fn jump_lengths(&self) -> Option { + self.jump_lengths + } + + /// The node-time mapping of this block for a node of input arrival `arrival` (`a(n)`, the + /// floored arrival of #1285): the timeline read from source-read sample `r + ΣP - a(n)`, + /// which is negative while `r + ΣP < a(n)` (R17-5). The difference is exact in `i128`. `None` + /// in a plan with no timeline or when the block's source-read sample is unknown. + #[must_use] + pub fn node_span(&self, arrival: u64) -> Option { + let first = i128::from(self.source_read_sample?) - i128::from(arrival); + self.timeline?.timeline_block(first, self.frames) + } +} +// REALTIME_POLICY_END + /// What the plan's jump-length cell shares between its writer (control) and its reader (render): /// one #1312 latest-target cell and its dirty word (#1639 D1). struct JumpLengthLane { @@ -1734,6 +1821,21 @@ pub trait GraphPreparedBuiltinBankProcessor: Send + Any { fn pair_factory(&self) -> Option { None } + /// Take the floored input arrival `a(n)` of each member, in lane order (#1647a, R17-2): + /// `arrivals[l]` is lane `l`'s, and a lane past the slice is padding. The graph compiler calls + /// it once, off the render thread, at the end of `compile_with_builtins`, from + /// `GraphCompileReport::input_arrivals` (#1285). The default keeps nothing. + fn set_input_arrivals(&mut self, arrivals: &[u64]) { + let _ = arrivals; + } + /// Take this block's node-time context (#1647a, R17-3). The graph calls it on every builtin + /// slot of a bank unit once per block, before the unit's chain runs, so `begin_block` and + /// `process` of the same block read what it kept. The context borrows the source set only for + /// the call. Render-thread code: allocation-, lock- and syscall-free and bounded. The default + /// keeps nothing. + fn set_block_time(&mut self, time: &GraphBlockTime<'_>) { + let _ = time; + } fn process( &mut self, left: &mut [f32], @@ -2011,6 +2113,27 @@ impl PreparedGraphPlan { control_delivery: bank.processor.control_delivery(), }) } + /// Give each builtin bank its members' floored input arrivals `a(n)` (#1647a, R17-2), through + /// [`GraphPreparedBuiltinBankProcessor::set_input_arrivals`]. `arrivals` is + /// `GraphCompileReport::input_arrivals` (#1285 D2): every node with a nonzero floored arrival, + /// sorted by node ID; a member absent from it has arrival 0. Off the render thread, once, + /// after the banks are attached and before bind. + pub fn install_builtin_input_arrivals(&mut self, arrivals: &[(GraphNodeId, LatencySamples)]) { + for bank in &mut self.builtin_banks { + let lanes: Vec = bank + .members + .iter() + .map(|member| { + arrivals + .binary_search_by(|(node, _)| node.cmp(member)) + .ok() + .and_then(|row| arrivals.get(row)) + .map_or(0, |(_, arrival)| arrival.0) + }) + .collect(); + bank.processor.set_input_arrivals(&lanes); + } + } /// Attach sealed fixed-stage banks before binding. The graph compiler remains responsible /// for deciding eligibility; this validates only the immutable graph shape. pub fn with_builtin_banks( @@ -2672,6 +2795,18 @@ pub trait GraphPreparedSourceSetDriver: Send { fn timeline_at(&self, _source_read_sample: u64) -> Option { None } + /// The node-time mapping of the node block of `frames` samples from the signed source-read + /// sample `first_source_read_sample` (#1640 D3, #1647a), from the driver's timeline history, + /// or `None` (the default) for a driver with no timeline. Render reads it once per automated + /// lane per block, through [`GraphBlockTime::node_span`]. Read-only, allocation-free and + /// bounded; no input overflows or panics (R17-5). + fn timeline_block( + &self, + _first_source_read_sample: i128, + _frames: u32, + ) -> Option { + None + } } /// The source set as a bank's gather sees it during the unit loop (issue #918): a shared view of @@ -4982,10 +5117,10 @@ impl PreparedPlanExecutor for GraphExecutor { if let Some(probe) = probe.as_mut() { probe.enter(test_only_phase_profile::SOURCE); } + // #1396 D1: the source-read sample, `ΣP` ahead of the render clock. + let block_source_read_sample = time.absolute_sample.checked_add(*source_read_offset); let source_validity = if let Some(source_set) = source_set.as_mut() { - // #1396 D1: the source-read sample, `ΣP` ahead of the render clock. - let Some(source_read_sample) = time.absolute_sample.checked_add(*source_read_offset) - else { + let Some(source_read_sample) = block_source_read_sample else { runtime.invalidate_observers_after_failure(time.absolute_sample); host.silence(); return Err(RenderError::TimeOverflow); @@ -5018,6 +5153,16 @@ impl PreparedPlanExecutor for GraphExecutor { // `&mut`, so none can run while a unit borrows a plane; the next `begin_block` is the // next render's. let sources = source_set.as_ref().map(|set| set as &dyn GraphSourcePlanes); + // #1647a (R17-3): the block's node-time context, set on every builtin slot of a bank unit + // before its chain runs. + let block_time = GraphBlockTime::new( + block_source_read_sample, + u32::try_from(runtime.arena.frames()).unwrap_or(u32::MAX), + jump_lengths.as_deref().map(|reader| reader.held), + source_set + .as_ref() + .map(|set| &*set.driver as &dyn GraphPreparedSourceSetDriver), + ); // Issue #936: only the units bind found to do work. An inert unit's `execute` and // `observe_unit` return at once, so skipping it moves no bit. #[cfg(any(test, feature = "test-support"))] @@ -5031,9 +5176,14 @@ impl PreparedPlanExecutor for GraphExecutor { if let Some(probe) = probe.as_mut() { probe.unit(runtime.test_only_unit_phase(unit)); } - if let Err(error) = - runtime.execute(unit, time.absolute_sample, live, host.reborrow(), sources) - { + if let Err(error) = runtime.execute( + unit, + time.absolute_sample, + live, + host.reborrow(), + sources, + &block_time, + ) { runtime.invalidate_observers_after_failure(time.absolute_sample); host.silence(); #[cfg(any(test, feature = "test-support"))] diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index 046029a98..a46f8ab1b 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -3185,6 +3185,9 @@ impl Runtime { /// with no source set. One read uses it: a bank's gather, only on a lane its unit marks /// (`UnitIdentity::source_lanes`) for a buffer [`Self::source_plane_of_buffer`] names. Every /// other read of the arena is unchanged. + /// + /// `block_time` is the block's node-time context (#1647a, R17-3): a bank unit sets it on each + /// of its builtin slots before its chain runs. Other units ignore it. pub(crate) fn execute( &mut self, index: usize, @@ -3192,6 +3195,7 @@ impl Runtime { snapshot: LiveSnapshot, host: HostMaster<'_>, sources: Option<&dyn crate::GraphSourcePlanes>, + block_time: &crate::GraphBlockTime<'_>, ) -> Result<(), RenderError> { let Self { arena, @@ -3301,6 +3305,7 @@ impl Runtime { }, }; let frames = u32::try_from(frames).unwrap_or(u32::MAX); + set_block_time(chain, block_time); if let Some(predecessor) = predecessor { #[cfg(any(test, feature = "test-support"))] TEST_ONLY_RESIDENT_COUNTS.with(|count| { @@ -4397,6 +4402,18 @@ fn builtin_processor_mut( Some(stage.0.as_mut()) } +/// Set the block's node-time context on every builtin slot of `chain` (#1647a, R17-3), before +/// the chain runs. Allocation-free and O(slots): a slot borrow, two downcasts and one call each. +fn set_block_time(chain: &mut BankChain, time: &crate::GraphBlockTime<'_>) { + let mut slot = 0; + while chain.slot_stage_mut(slot).is_some() { + if let Some(processor) = builtin_processor_mut(chain, slot) { + processor.set_block_time(time); + } + slot += 1; + } +} + /// Bit `lane` of a carry mask. A bank has at most eight lanes, the width of the `u8` mask: debug /// builds refuse a lane beyond it, and a release build drops it from the mask rather than adding a /// panic edge to the realtime path. @@ -13878,7 +13895,19 @@ mod tests { before_master = Some(stereo_bits(runtime.buffer(slot))); } runtime - .execute(unit, first_sample, LiveSnapshot::ALL, host.reborrow(), None) + .execute( + unit, + first_sample, + LiveSnapshot::ALL, + host.reborrow(), + None, + &crate::GraphBlockTime::new( + Some(first_sample), + u32::try_from(frames).expect("oracle frames"), + None, + None, + ), + ) .expect("oracle unit"); runtime .observe_unit(unit, first_sample, validity, &host) diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index cd10d5dcb..34b36e2cc 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -1,7 +1,9 @@ //! Stored fader automation at preparation (#1646): its bytes are charged as builtin bank payload //! (gate 2), and a fader automation keeps a mono track's collapse (gate 3). Gate 1 (flat renders //! the static bits) is on the two hosts: `crates/capi/src/runtime/tests.rs` and -//! `hosts/host-web/src/tests.rs`. +//! `hosts/host-web/src/tests.rs`. The render plumbing (#1647a): each automated fader lane takes +//! its `a(n)` and, per block, its node-time mapping and the fader row's jump length, with no +//! render allocation. use automation::ProgramBuilder; use builtins_compiler::fader_automation_sizes; @@ -11,8 +13,10 @@ use host_core::{ prepare_host_session, }; use session::{ - Automation, AutomationSegment, AutomationShape, ControlAddress, Owner, ParameterChannel, - ParameterUnit, Section, SessionModel, StableId, canonical_session_json, parse_session_json, + Automation, AutomationSegment, AutomationShape, ControlAddress, ControlSmoothing, Effect, + EffectIdentity, EffectParam, EffectQuality, LinkMode, Owner, ParameterChannel, ParameterUnit, + Section, SessionModel, SidechainDeclaration, StableId, canonical_session_json, + parse_session_json, }; /// Eight tracks on one two-channel source, one banked parametric EQ each as the console slot. @@ -277,3 +281,152 @@ fn a_left_only_fader_automation_keeps_a_mono_tracks_collapse() { ); } } + +/// `model` with a true-peak limiter insert of 5 ms lookahead on track `track`: a latent node +/// before the fader. +fn with_limiter(mut model: SessionModel, track: usize) -> SessionModel { + let param = |parameter_id, unit, value| EffectParam { + parameter_id, + channel: ParameterChannel::Both, + unit, + value, + }; + let strip = &mut model.tracks[track]; + strip.inserts.effects.push(Effect { + id: id(&format!("limiter-{track}")), + identity: EffectIdentity::Native { + effect_id: id("miso.true-peak-limiter"), + }, + quality: EffectQuality::Normal, + bypass: false, + link_mode: LinkMode::Maximum, + params: vec![ + param(1, ParameterUnit::Db, -8.0), + param(2, ParameterUnit::Milliseconds, 60.0), + param(3, ParameterUnit::Milliseconds, 5.0), + ], + sidechain: SidechainDeclaration::None, + }); + model +} + +/// A moving fader automation on the left lane of track `track`: `-40` to `0` dB over a second. +fn fader_ride(model: &SessionModel, track: usize) -> Automation { + let mut entry = flat_fader(model, track, ParameterChannel::Left, -40.0); + entry.segments[0].end_value = 0.0; + entry +} + +/// The floored input arrival `a(n)` of track `track`'s fader node in `prepared`'s plan: its row in +/// the plan inventory's input floors (#1285), or 0. +fn fader_arrival(prepared: &PreparedHost, model: &SessionModel, track: usize) -> u64 { + let track_id = model.tracks[track].id.as_str(); + prepared + .inventory + .input_floors() + .iter() + .find(|(node, _)| { + matches!( + node, + graph::GraphNodeId::TrackStage { + track_id: id, + stage: graph::TrackStage::PostFader, + } if id.as_str() == track_id + ) + }) + .map_or(0, |(_, arrival)| *arrival) +} + +/// #1647a (R17-2, R17-3): two automated fader lanes, the first behind a latent insert. At every +/// block each lane's bank holds its `a(n)` (the plan's floored arrival), the node-time mapping +/// `timeline_block(r + ΣP - a(n))` (`-a(n)` at render sample 0 of a fresh plan, then the render +/// clock behind by `a(n)`, with no step) and the fader row's jump length from the session's +/// `fader_ms`; rendering those blocks after a warm-up allocates and frees nothing. +/// +/// Test value: red if the graph compiler does not hand `a(n)` to the bank (or hands the natural +/// arrival or another member's), if the graph does not set a block's context before the chain +/// runs (a stale or missing span), if the signed read clamps a negative node sample to 0, if the +/// jump length is read from the mute or pan key, or if the context-set path allocates. +#[test] +fn each_automated_fader_lane_takes_its_arrival_and_node_time_per_block() { + const PROBED: usize = 12; + let mut model = with_limiter(bank_model(), 0); + model.control_smoothing = Some(ControlSmoothing { + mute_ms: 5.0, + fader_ms: 20.0, + pan_ms: 10.0, + }); + model.automation = vec![fader_ride(&model, 0), fader_ride(&model, 1)]; + let mut prepared = prepare(&model, 100_000_000).expect("prepares"); + let latent = fader_arrival(&prepared, &model, 0); + let plain = fader_arrival(&prepared, &model, 1); + assert!(latent > 0, "the limiter delays track 0's fader"); + assert_eq!(plain, 0, "track 1's fader has no latency before it"); + let fader_jump = controls::LiveRamps::for_session(&model).fader_samples; + assert_eq!(fader_jump, 960, "20 ms at 48 kHz"); + + let _ = builtins_compiler::test_only_take_fader_lane_times(); + let mut allocations = (0, 0, 0); + for block in 0..PROBED { + let base = block * QUANTUM; + let plane: Vec = (0..QUANTUM).map(|frame| signal(base + frame)).collect(); + prepared + .sources + .submit( + b"fixture-source", + SourceSubmission { + generation: 1, + start_frame: base as u64, + sample_rate_hz: RATE, + planes: &[&plane, &plane], + frames: QUANTUM as u32, + end_of_region: false, + }, + ) + .expect("source block"); + let mut samples = [0.0_f32; QUANTUM * 2]; + let output = + PlanarBufferMut::try_new(&mut samples, 2, QUANTUM, QUANTUM).expect("output planes"); + let mark = bench_support::alloc::current_thread_counters(); + prepared + .plan + .render( + RenderIo { output }, + RenderTime { + absolute_sample: base as u64, + }, + ) + .expect("render"); + let delta = bench_support::alloc::current_thread_delta_since(mark); + if block >= 2 { + allocations.0 += delta.allocations; + allocations.1 += delta.reallocations; + allocations.2 += delta.deallocations; + } + let (times, overflow) = builtins_compiler::test_only_take_fader_lane_times(); + assert!(!overflow, "block {block}: the probe holds every lane"); + assert_eq!( + times.len(), + 2, + "block {block}: one entry per automated lane" + ); + let mut arrivals: Vec = times.iter().map(|time| time.arrival).collect(); + arrivals.sort_unstable(); + assert_eq!(arrivals, [plain, latent], "block {block}: each lane's a(n)"); + for time in × { + let start = base as i64 - time.arrival as i64; + assert_eq!( + time.span, + Some(graph::TimelineSpan { start, step: None }), + "block {block}, lane {}: node time r + ΣP - a(n)", + time.lane + ); + assert_eq!(time.fader_jump, Some(fader_jump), "block {block}"); + } + } + assert_eq!( + allocations, + (0, 0, 0), + "the context-set path allocates and frees nothing" + ); +} diff --git a/crates/source/src/lib.rs b/crates/source/src/lib.rs index 63a393b5b..345ae6d88 100644 --- a/crates/source/src/lib.rs +++ b/crates/source/src/lib.rs @@ -2588,24 +2588,9 @@ struct TimelineEntry { timeline: u64, } -/// The node-time mapping of one node block (#1640 D3). -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct TimelineSpan { - /// The timeline value at the node block's first sample. - pub start: i64, - /// The seek that reaches the node block, if any. - pub step: Option, -} - -/// A seek inside a node block: from sample `offset` of the block on, the timeline reads -/// `timeline + (i - offset)`. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct TimelineStep { - /// The node block's sample the seek takes effect at, from `0` to the block length minus one. - pub offset: u32, - /// The timeline value at that sample. - pub timeline: i64, -} +/// The node-time mapping of one node block (#1640 D3) and a seek inside it: the graph's types, +/// so the source driver's `timeline_block` (#1647a) hands render the reader's own value. +pub use graph::{TimelineSpan, TimelineStep}; /// The session's playhead (#1640 D2): a source consumer with no PCM, no data queue and no /// recycle queue. It starts at generation 1, timeline sample 0, takes plain and anchored seeks @@ -2774,7 +2759,7 @@ impl TimelineConsumer { /// position stands at source-read sample 0. Exact; saturates at the `i64` range. #[must_use] pub fn timeline_at(&self, source_read_sample: u64) -> i64 { - self.resolve(source_read_sample).1 + self.resolve(i128::from(source_read_sample)).1 } /// The node-time mapping of the node block of `frames` samples from @@ -2787,9 +2772,15 @@ impl TimelineConsumer { /// a host renders without a step-back inside it, agrees with `timeline_at` on every sample; /// of several, the first is reported. Read-only and allocation-free, with at most /// `6K² + 10K + 1` entry reads. + /// + /// The first sample is signed (#1647a, R17-5): a node of input arrival `a(n)` reads the block + /// from `r + ΣP - a(n)`, which is negative while `r + ΣP < a(n)`; such a sample extrapolates + /// back from the oldest entry, so a fresh plan's node reads `-a(n)` at render sample 0. Every + /// `i128` value is taken: the arithmetic is exact (a `u64` entry against an `i128` sample), + /// a value past the `i64` range saturates to it, and nothing wraps or panics. #[must_use] - pub fn timeline_block(&self, first_source_read_sample: u64, frames: u32) -> TimelineSpan { - let start = self.timeline_at(first_source_read_sample); + pub fn timeline_block(&self, first_source_read_sample: i128, frames: u32) -> TimelineSpan { + let start = self.resolve(first_source_read_sample).1; let quantum = u64::from(self.quantum_frames); let mut step: Option = None; for age in 0..self.len { @@ -2800,7 +2791,7 @@ impl TimelineConsumer { entry.source_read_sample, entry.source_read_sample.saturating_add(quantum), ] { - let Some(offset) = sample + let Some(offset) = i128::from(sample) .checked_sub(first_source_read_sample) .and_then(|offset| u32::try_from(offset).ok()) .filter(|&offset| offset < frames) @@ -2824,10 +2815,10 @@ impl TimelineConsumer { /// `sample` is also a step when it is a seek, read against `oldest_expected_timeline` for the /// oldest entry: the history no longer holds the block it stepped from. fn step_at(&self, sample: u64) -> Option { - let (resolving, timeline) = self.resolve(sample); + let (resolving, timeline) = self.resolve(i128::from(sample)); let stepped = match sample.checked_sub(1) { Some(previous) => { - self.resolve(previous).1.checked_add(1) != Some(timeline) + self.resolve(i128::from(previous)).1.checked_add(1) != Some(timeline) || (!self.starts_at_or_before(previous) && resolving.is_some_and(|age| self.is_seek_entry(age))) } @@ -2906,14 +2897,17 @@ impl TimelineConsumer { /// The age of the entry whose block contains `sample` (`None` when the value is /// extrapolated), and the timeline value at `sample`. - fn resolve(&self, sample: u64) -> (Option, i64) { - let quantum = u64::from(self.quantum_frames); + fn resolve(&self, sample: i128) -> (Option, i64) { + let quantum = i128::from(self.quantum_frames); let mut before = None; for age in 0..self.len { let Some(entry) = self.entry(age) else { break; }; - if let Some(offset) = sample.checked_sub(entry.source_read_sample) { + if let Some(offset) = sample + .checked_sub(i128::from(entry.source_read_sample)) + .filter(|&offset| offset >= 0) + { if offset < quantum { return (Some(age), offset_timeline(entry, sample)); } @@ -2937,9 +2931,12 @@ impl TimelineConsumer { } /// `entry.timeline + (sample - entry.source_read_sample)`, exact, saturated to the `i64` range. -fn offset_timeline(entry: TimelineEntry, sample: u64) -> i64 { - let value = - i128::from(entry.timeline) + i128::from(sample) - i128::from(entry.source_read_sample); +/// `sample` is signed (#1647a): it may lie before source-read sample 0. The `i128` sum saturates +/// at the `i128` range first, which lies far outside the `i64` one, so no input wraps. +fn offset_timeline(entry: TimelineEntry, sample: i128) -> i64 { + let value = i128::from(entry.timeline) + .saturating_add(sample) + .saturating_sub(i128::from(entry.source_read_sample)); i64::try_from(value).unwrap_or(if value < 0 { i64::MIN } else { i64::MAX }) } // REALTIME_POLICY_END @@ -3361,6 +3358,15 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { fn timeline_at(&self, source_read_sample: u64) -> Option { Some(self.timeline.timeline_at(source_read_sample)) } + + /// The node-time mapping of the node block of `frames` samples from the signed + /// `first_source_read_sample` (#1647a), from the timeline's history: render's per-block read. + fn timeline_block(&self, first_source_read_sample: i128, frames: u32) -> Option { + Some( + self.timeline + .timeline_block(first_source_read_sample, frames), + ) + } // REALTIME_POLICY_END fn as_any(&self) -> Option<&dyn core::any::Any> { @@ -6875,6 +6881,58 @@ mod tests { } } + /// #1647a (R17-5): the signed reader at the limits. A node block may start before + /// source-read sample 0 (`r + ΣP < a(n)`) and is read back from the oldest entry, so a + /// fresh plan's node of arrival 300 reads `-300` at render sample 0, and a block that + /// crosses sample 0 has no step. First samples below `i64::MIN`, at `i64::MIN`, at 0, at + /// `i64::MAX`, above it (up to `i128::MAX`), and blocks that cross each of these and the + /// `u64` end of the source-read clock, give saturated `i64` values and no panic. + /// + /// Test value: red if the signed first sample wraps, panics or saturates a value inside + /// the `i64` range, or if a block that crosses sample 0 reports a step. + #[test] + fn the_signed_reader_extrapolates_back_and_saturates_at_the_limits() { + let (_producer, mut consumer) = timeline(4); + blocks_at(&mut consumer, &[0]); + let plain = |start| TimelineSpan { start, step: None }; + assert_eq!(consumer.timeline_block(-300, Q), plain(-300)); + assert_eq!(consumer.timeline_block(-40, Q), plain(-40)); + assert_eq!(consumer.timeline_block(0, Q), plain(0)); + let span = consumer.timeline_block(-40, Q); + for i in 0..Q { + let sample = -40 + i64::from(i); + assert_eq!(span_value(span, i), sample, "sample {i} across 0"); + } + let min = i128::from(i64::MIN); + let max = i128::from(i64::MAX); + for (first, start) in [ + (i128::MIN, i64::MIN), + (min - 1, i64::MIN), + (min, i64::MIN), + (min + 40, i64::MIN + 40), + (max - 40, i64::MAX - 40), + (max, i64::MAX), + (max + 1, i64::MAX), + (i128::from(u64::MAX), i64::MAX), + (i128::MAX - 40, i64::MAX), + (i128::MAX, i64::MAX), + ] { + assert_eq!(consumer.timeline_block(first, Q), plain(start), "{first}"); + } + // A history at the `u64` end of the source-read clock: an entry whose block ends + // past `u64::MAX` and node blocks that cross that end. + let (_producer, mut end) = timeline(4); + blocks_at(&mut end, &[u64::MAX - 100]); + assert_eq!(end.timeline_at(u64::MAX), 100); + assert_eq!(end.timeline_block(i128::from(u64::MAX) - 40, Q), plain(60)); + assert_eq!(end.timeline_block(i128::from(u64::MAX), Q), plain(100)); + assert_eq!(end.timeline_block(i128::from(u64::MAX) + 1, Q), plain(101)); + assert_eq!(end.timeline_block(i128::MAX, Q), plain(i64::MAX)); + assert_eq!(end.timeline_block(i128::MIN, Q), plain(i64::MIN)); + // Sample -1 lies `u64::MAX - 99` before the entry: past the `i64` range, saturated. + assert_eq!(end.timeline_block(-1, Q), plain(i64::MIN)); + } + /// Batch 16a fold M1: a source-read clock step-back (#1641 D4) overlaps a newer entry on /// an older one, so the timeline steps where the newer entry starts and again where it /// ends, with no seek entry in sight. @@ -6910,7 +6968,7 @@ mod tests { ); // Every node block with one step at most agrees with `timeline_at` sample by sample. for first in (0..5 * QU).step_by(8) { - let span = consumer.timeline_block(first, Q); + let span = consumer.timeline_block(i128::from(first), Q); for i in 0..Q { assert_eq!( span_value(span, i), @@ -6988,7 +7046,7 @@ mod tests { if steps > 1 { continue; } - let span = consumer.timeline_block(first, Q); + let span = consumer.timeline_block(i128::from(first), Q); for i in 0..Q { assert_eq!( span_value(span, i), @@ -7048,8 +7106,8 @@ mod tests { // Evictions keep the oldest entry's expectation: a plain oldest entry is no step. let (_producer, mut plain) = timeline(3); run(&mut plain, 0, 6); - assert_eq!(plain.timeline_block(3 * QU, Q).step, None); - assert_eq!(plain.timeline_block(3 * QU - 40, Q).step, None); + assert_eq!(plain.timeline_block(i128::from(3 * QU), Q).step, None); + assert_eq!(plain.timeline_block(i128::from(3 * QU - 40), Q).step, None); } /// Batch 16a fold MINOR 3: a vacant timeline that no carry fills (#1641 D3). @@ -7203,7 +7261,7 @@ mod tests { run(&mut consumer, 3, 5); // Straddling: the node block starts 40 samples before the seek's block. assert_eq!( - consumer.timeline_block(3 * QU - 40, Q), + consumer.timeline_block(i128::from(3 * QU - 40), Q), TimelineSpan { start: 3 * 128 - 40, step: Some(TimelineStep { @@ -7214,7 +7272,7 @@ mod tests { ); // Exactly at the seek's block: the step is at offset 0. assert_eq!( - consumer.timeline_block(3 * QU, Q), + consumer.timeline_block(i128::from(3 * QU), Q), TimelineSpan { start: 10_000, step: Some(TimelineStep { @@ -7225,13 +7283,13 @@ mod tests { ); // A plain advance is no step, inside or at the first sample. assert_eq!( - consumer.timeline_block(4 * QU - 40, Q), + consumer.timeline_block(i128::from(4 * QU - 40), Q), TimelineSpan { start: 10_088, step: None, } ); - assert_eq!(consumer.timeline_block(2 * QU, Q).step, None); + assert_eq!(consumer.timeline_block(i128::from(2 * QU), Q).step, None); } #[test] @@ -7242,13 +7300,13 @@ mod tests { // Block 2 seeks; block 3 evicts block 1, so block 2 becomes the oldest entry. run(&mut consumer, 2, 4); assert_eq!( - consumer.timeline_block(2 * QU, Q).step, + consumer.timeline_block(i128::from(2 * QU), Q).step, Some(TimelineStep { offset: 0, timeline: 9_000, }) ); - assert_eq!(consumer.timeline_block(3 * QU, Q).step, None); + assert_eq!(consumer.timeline_block(i128::from(3 * QU), Q).step, None); } #[test] @@ -7273,7 +7331,7 @@ mod tests { vec![(5 * QU, 10_128), (6 * QU, 10_256), (7 * QU, 10_384)] ); assert_eq!(small.timeline_at(5 * QU - 1), 10_127); - assert_eq!(small.timeline_block(5 * QU, Q).step, None); + assert_eq!(small.timeline_block(i128::from(5 * QU), Q).step, None); // From a smaller K: all three, in order, then the copy keeps recording after them. let (_p, mut larger) = timeline(5); larger.copy_history_from(&small); @@ -7290,7 +7348,7 @@ mod tests { four.copy_history_from(&large); assert_eq!(snapshot(&four).first(), Some(&(4 * QU, 10_000))); assert_eq!( - four.timeline_block(4 * QU, Q).step, + four.timeline_block(i128::from(4 * QU), Q).step, Some(TimelineStep { offset: 0, timeline: 10_000, From 63d27a212c9d08ca97aeb17218a29afa76730fa2 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 18:32:42 +0000 Subject: [PATCH 09/35] Prepare every fader inside CanonicalFpEnv; fold the U1 review (#1646) MAJOR 1: the builtins preparation body, both graph lowerings and host-core's static VCA composition run inside CanonicalFpEnv, so the prepared fader bits do not depend on the caller's floating-point environment; the dead black_box check is gone. Test: a hostile caller environment (FTZ, DAZ, round toward zero) prepares the canonical faders. MINOR 1: the scalar owner row charge is tested. MINOR 2: gate 2 cross-checks the retained heap with thread-scoped alloc counters (1,212 B per automated lane on x86-64 AVX2). MINOR 3: comments state the validator refuses every per-entry cause first. NIT 1 recorded as a departure (+16 B per fader bank). pcm_digest unchanged. Co-Authored-By: Claude Opus 5.5 --- .../1646-prepare-stored-fader-automation.md | 41 ++++ ...-automated-fader-or-trim-by-a-hand-move.md | 2 +- crates/builtins-compiler/src/lib.rs | 145 ++++++++++-- crates/host-core/src/prepare.rs | 8 +- .../tests/stored_fader_automation.rs | 211 +++++++++++++++++- 5 files changed, 379 insertions(+), 28 deletions(-) diff --git a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md index 41b952f34..d4de070ac 100644 --- a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md @@ -582,3 +582,44 @@ grows by 16 bytes per fader bank, a fixed structural change inside every ceiling fader owner's new `automation` field. Gate 6 landed with #1646a. `S` in `vca_reach_counts_a_diamond_once_and_includes_nested_parents_in_id_order` (`crates/controls/tests/composition.rs`) now also pins `offsets_sum`, `+0.0` by bits. + +## U1 fold (batch 17) + +Folds the U1 review of #1646b. + +- **D2, canonical environment (MAJOR 1).** The builtins preparation body + (`prepare_session_builtins_with_live_controls_and_policy`) holds `lane::CanonicalFpEnv` for its + whole run, and both lowerings (`into_graph_artifact_with_banks`, the oracle's + `into_graph_artifact`) hold their own, so every strip's fader gain and input coefficients, + automated or static, are baked in the canonical environment. host-core's + `prepare_host_runtime_with_live_controls_policy_and_spectrum` computes `controls::effective` (the static VCA composition) inside + the guard too, so a flat automated lane and a static fader compose and convert alike by + construction. An automated lane's composed dB is computed once in `prepare_fader_automation`, + stored on its `PreparedFaderCell` and read by `FaderAutomationPlan::fader_db`; the discarded + `black_box` conversion is gone. Its domain check stays as a defensive typed refusal. + Gate: `canonical_preparation::a_hostile_caller_environment_prepares_the_canonical_faders` + (`crates/host-core/tests/stored_fader_automation.rs`, x86-64 and AArch64): a caller word with + FTZ, DAZ and round-toward-zero prepares the plan a canonical thread prepares, bit for bit over 16 + blocks; the fixture asserts its own conversions and one VCA composition round differently under + that word. Red, checked once, with any one of the three guards removed (body, bank lowering, + host-core effective). +- **`host.automation.fader` is defensive (MINOR 3, follow-up of departure 2).** A validated + session reaches none of its causes: the validator refuses an empty segment list, a `start_value` + outside the fader domain (`vca_compose_db` then clamps), and every segment the builder refuses. + Only `TooLarge` and `Allocation` (at `$.automation`) can reach a validated session. The code + comments say so; no test reaches the per-entry refusal, because no validated session can. The + host diagnostics are not listed in `docs/SESSION_SCHEMA_V1.md`, so no doc row is added. +- **Scalar oracle charge (MINOR 1, R17-4).** Gate: + `tests::the_scalar_owner_row_charges_one_automated_fader_lane` (`crates/builtins-compiler`): + at `Backend::Scalar` the owner row grows by `size_of::()` plus one offsets + cell plus the program (overhead and report), once. +- **Gate 2 cross-check (MINOR 2).** `a_fader_automation_is_charged_to_the_graph_plan_rows_and_the_cap` + now also measures the heap with `bench_support::alloc`'s thread-scoped counters: the automated + prepared host holds exactly the charge (1,212 bytes on x86-64 AVX2) more than the plain one. +- **Departure (NIT 1).** `FaderBankProcessor::automation` is a `Box<[Option]>` + on every fader bank: `size_of` 16 bytes (x86-64), so `builtin_bank_bytes` grows by 16 per fader + bank for every session, with or without automation. Kept: an empty boxed slice does not allocate, + and one owner shape for every bank keeps the carry (#1648) and the render path (#1647) without a + second type. +- `./target/release/audit capi`: `pcm_digest` `bffc21e857b5e134` (unchanged), 0 violations, 0 + allocations. diff --git a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md index b69d6c37e..bd0cef981 100644 --- a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md +++ b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md @@ -412,7 +412,7 @@ moves to #1654 (render stored send gain, mute and matrix automation). No inert f ## Decision record: #1650a (batch 17) -Implemented on `codex/controls-batch-17w` over `ba240b6e1` (uncommitted for the coordinator). +Implemented on `codex/controls-batch-17w` over `ba240b6e1`, committed as `5c8781b7e`. **What shipped.** diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index a2bcff09c..48f3e67af 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -701,6 +701,10 @@ struct PreparedFaderCell { /// The prepared curve value: the entry's first `start_value`, the curve at node time `-a(n)` /// for every `a(n)` (D2, the hold rule). value: f32, + /// The lane's prepared fader dB, `vca_compose_db(value, S)`, composed once inside + /// [`lane::fpenv::CanonicalFpEnv`] (D2). It is the lane's prepared fader parameter, so the + /// lowering bakes it through the same `checked_fader_gain` path a static fader takes. + fader_db: f32, /// `S` (`controls::EffectiveStrip::offsets_sum`). offsets_sum: f64, /// The offsets cell's ramp word. @@ -723,17 +727,13 @@ impl FaderAutomationPlan { self.strips.get(track) } - /// The prepared dB of each lane of strip `track`: `vca_compose_db(value, S)` on an automated - /// lane (D2), `effective` elsewhere. + /// The prepared dB of each lane of strip `track`: the cell's composed `fader_db` on an + /// automated lane (D2), `effective` elsewhere. fn fader_db(&self, track: &str, effective: [f32; 2]) -> [f32; 2] { let Some(cells) = self.strip(track) else { return effective; }; - [0, 1].map(|lane| { - cells[lane].map_or(effective[lane], |cell| { - session::vca_compose_db(cell.value, cell.offsets_sum) - }) - }) + [0, 1].map(|lane| cells[lane].map_or(effective[lane], |cell| cell.fader_db)) } /// The bytes the program retains: its shared allocation and the builder's report. @@ -789,14 +789,19 @@ pub fn fader_automation_sizes() -> FaderAutomationSizes { /// a session without fader automation prepares exactly as before. /// /// Each lane's prepared value is its entry's first `start_value` and its `S` the strip's -/// [`controls::EffectiveStrip::offsets_sum`]; the composition `vca_compose_db(value, S)` and its -/// gain conversion run inside [`lane::fpenv::CanonicalFpEnv`], as render's do (D2). +/// [`controls::EffectiveStrip::offsets_sum`]; the composition `vca_compose_db(value, S)` runs +/// here, and its gain is baked by the lowering, both inside the [`lane::fpenv::CanonicalFpEnv`] +/// the caller holds, as render's are (D2). /// /// # Errors /// -/// `host.automation.fader` at the entry when a lane's composed value has no fader gain or the -/// program builder refuses its cell (a segment end at or past `2^53` samples), and at -/// `$.automation` when the program does not fit. +/// `host.automation.fader` at the entry when the entry has no segment, a lane's composed value +/// has no fader gain or the program builder refuses its cell, and at `$.automation` when the +/// program does not fit or cannot be allocated. The refusals at an entry are defensive: the +/// session validator refuses an empty segment list, a `start_value` outside the fader domain +/// (`vca_compose_db` then clamps), and every segment the builder would refuse (a time at or past +/// `2^53` samples, an order or spacing fault) before preparation, so a validated session reaches +/// none of them. They stay typed refusals so a broken invariant never panics in preparation. fn prepare_fader_automation( session: &CompiledSession, effective: &controls::Effective, @@ -828,19 +833,23 @@ fn prepare_fader_automation( for (lane, entry) in entries.into_iter().enumerate() { let Some(entry) = entry else { continue }; let segments = controls::stored_segments(entry); + // Defensive: the session validator refuses an entry with no segment. let value = segments.first().ok_or_else(|| refuse(entry))?.start_value; let offsets_sum = values.offsets_sum[lane]; - { - let _canonical = lane::fpenv::CanonicalFpEnv::enter(); - let composed = session::vca_compose_db(core::hint::black_box(value), offsets_sum); - core::hint::black_box(builtins::checked_fader_gain(composed)) - .map_err(|_| refuse(entry))?; - } + // The caller's body holds `CanonicalFpEnv` across this composition and the lowering + // that bakes its gain (D2). + let fader_db = session::vca_compose_db(value, offsets_sum); + // Defensive: the session validator bounds every `start_value` to the fader domain + // (#1645) and `vca_compose_db` clamps to it, so a validated session never fails this + // domain check. It refuses with a typed diagnostic rather than reach the lowering's + // `expect("preflighted parameters")`. + builtins::checked_fader_gain(fader_db).map_err(|_| refuse(entry))?; let cell = u32::try_from(tables.len()) .map_err(|_| diag("host.automation.fader", "$.automation"))?; cells[lane] = Some(PreparedFaderCell { cell, value, + fader_db, offsets_sum, offsets_ramp: ramp, }); @@ -853,6 +862,9 @@ fn prepare_fader_automation( .map(|(_, segments)| controls::FADER_CELL_RULE.describe(segments)) .collect(); let program_bytes = automation::ProgramBuilder::bytes(&descriptions); + // A refusal at a cell is defensive: the session validator refuses every segment the builder + // refuses (#1646b departure 2, batch 17 follow-up), so only `TooLarge` and `Allocation`, the + // program's own size and memory, can reach a validated session. let program = automation::ProgramBuilder::build(&descriptions).map_err(|error| { let cell = match error { automation::BuildError::NoSegments { cell } @@ -3737,6 +3749,8 @@ impl PreparedBuiltinsSession { graph: PreparedGraphPlan, report: R, ) -> PreparedBuiltinsGraphArtifact { + // #1646 D2: the scalar oracle rebuilds the ramped fader from the prepared parameters here. + let _canonical = lane::CanonicalFpEnv::enter(); let mut processors = core::mem::take(&mut self.processors); processors.append(&mut self.strip_bindings()); PreparedBuiltinsGraphArtifact { @@ -4130,6 +4144,9 @@ impl PreparedBuiltinsSession { levels: &[DependencyLevel], classes: &SessionPoolClasses, ) -> PreparedBuiltinsGraphArtifact { + // #1646 D2: the banks bake each lane's prepared fader gain and input coefficients here, so + // lowering runs in the canonical floating-point environment, as preparation does. + let _canonical = lane::CanonicalFpEnv::enter(); let Some(width) = BankWidth::for_backend(dispatch) else { #[cfg(any(test, feature = "test-support"))] return self.into_graph_artifact(graph, report); @@ -5040,6 +5057,13 @@ fn prepare_session_builtins_with_live_controls_and_policy( caps: BuiltinCompileCaps, control_delivery: BuiltinControlDelivery, ) -> Result { + // #1646 D2: every curve evaluation and conversion of preparation runs in the canonical + // floating-point environment, as render's do. The whole body is held so a strip's prepared + // fader (automated or static) and its input coefficients are computed under one environment, + // whatever the caller's thread has set: a flat automated lane bakes the bits a static fader + // bakes by construction. The lowerings hold their own (`into_graph_artifact_with_banks`, + // `into_graph_artifact`). + let _canonical = lane::CanonicalFpEnv::enter(); let mut diagnostics = Vec::new(); if [ caps.maximum_total_state_bytes, @@ -13552,6 +13576,91 @@ mod tests { ); } + /// #1646 D4 (R17-4): the scalar oracle's owner row charges one automated fader lane exactly: + /// the boxed `LaneFaderAutomation` its strip's fader owner holds, one offsets cell, and the + /// program once (its shared allocation and the builder's byte report). + /// + /// Test value: red if the scalar owner row drops or double-counts any term of the stored + /// fader automation (the boxed slot, the offsets cell or the program), which no vector-bank + /// test sees because a vector dispatch leaves no scalar owner. + #[test] + fn the_scalar_owner_row_charges_one_automated_fader_lane() { + let plain = n_track_session(3); + let mut model = plain.normalized_model().clone(); + let entry = session::Automation { + id: session::StableId::parse("fader-t01").expect("id"), + target: session::ControlAddress { + owner: session::Owner::Strip( + session::StableId::parse(&track_name(1)).expect("track"), + ), + section: session::Section::Builtins, + control: 5, + lane: session::ParameterChannel::Left, + }, + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: 4_096, + start_value: -6.5, + end_value: -2.0, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + }; + let segments = controls::stored_segments(&entry); + model.automation = vec![entry]; + let automated = compile_session( + &model, + CompileCaps { + max_compiled_model_bytes: u64::MAX, + max_requested_runtime_bytes: u64::MAX, + max_single_allocation_bytes: u64::MAX, + max_queue_items: u64::MAX, + max_source_ring_frames: u64::MAX, + max_source_ring_bytes: u64::MAX, + }, + ) + .expect("the automated session compiles"); + let controls = (0..3) + .map(|index| TrackControlRequest { + track_id: track_name(index), + superseded: LiveValuesSuperseded::default(), + input_lane: false, + }) + .collect::>(); + let (_, levels) = track_graph(3); + let scalar = |compiled: &CompiledSession| { + let classes = SessionPoolClasses::from_session(compiled); + prepare_session_builtins_between_render_calls( + compiled, + &effective_of(compiled), + &[], + &controls, + caps(), + ) + .expect("scalar owners") + .graph_scalar_owner_resource(Backend::Scalar, &levels, &classes) + .expect("checked scalar estimate") + }; + let without = scalar(&plain); + let with = scalar(&automated); + let sizes = fader_automation_sizes(); + let program = + automation::ProgramBuilder::bytes(&[controls::FADER_CELL_RULE.describe(&segments)]); + assert!(program > 0, "the program holds the segment"); + let slot = core::mem::size_of::() as u64; + assert_eq!( + with.total_bytes - without.total_bytes, + slot + sizes.offsets_cell_bytes + sizes.program_overhead_bytes + program, + "one boxed slot, one offsets cell and the program, once" + ); + assert_eq!( + with.split_pair_table_bytes, without.split_pair_table_bytes, + "the automation adds no split table entry" + ); + assert!(with.largest_allocation_bytes >= slot.max(sizes.offsets_cell_bytes).max(program)); + } + struct HarnessSink; impl GraphRuntimeProcessor for HarnessSink { diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index dd8895ea7..2b24dda84 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -2753,7 +2753,13 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( }; // Decision 17, C7: every effective value is computed once per preparation, here, under the // overlay the successor renders with (#1559 D9); a first plan has no solo. - let effective = controls::effective(compiled.normalized_model(), overlay); + // #1646 D2: composed in the canonical floating-point environment, as the builtins + // preparation composes an automated lane, so a flat automated lane and a static fader round + // alike whatever the caller's thread has set. + let effective = { + let _canonical = lane::CanonicalFpEnv::enter(); + controls::effective(compiled.normalized_model(), overlay) + }; let builtins = if selected_meters.is_some() { // #1401 D2: a selected-meter request follows the caller's delivery contract, like every // other request. The serialized form keeps its fused fader and matrix banks. diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index cd10d5dcb..c769bab4d 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -1,7 +1,11 @@ //! Stored fader automation at preparation (#1646): its bytes are charged as builtin bank payload //! (gate 2), and a fader automation keeps a mono track's collapse (gate 3). Gate 1 (flat renders //! the static bits) is on the two hosts: `crates/capi/src/runtime/tests.rs` and -//! `hosts/host-web/src/tests.rs`. +//! `hosts/host-web/src/tests.rs`. Preparation bakes every fader in the canonical floating-point +//! environment, whatever the caller's thread has set (D2). + +// The D2 environment test writes the thread's floating-point control word, which is `unsafe`. +#![allow(unsafe_code)] use automation::ProgramBuilder; use builtins_compiler::fader_automation_sizes; @@ -86,6 +90,19 @@ fn flat_fader( } } +/// [`prepare`], and the heap bytes the preparation left live on this thread (the audited +/// allocator's thread-scoped counters: requested minus released), which the prepared host holds: +/// the compiled model is dropped inside the window. +fn prepare_retained(model: &SessionModel) -> (PreparedHost, u64) { + let document = canonical_session_json(model).expect("canonical"); + let mark = bench_support::alloc::current_thread_counters(); + let prepared = prepare_host_session(&document, &caps(u64::MAX)) + .map(|(_, prepared)| prepared) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + let moved = bench_support::alloc::current_thread_delta_since(mark); + (prepared, moved.requested_bytes - moved.released_bytes) +} + fn prepare(model: &SessionModel, maximum: u64) -> Result { let document = canonical_session_json(model).expect("canonical"); prepare_host_session(&document, &caps(maximum)) @@ -116,13 +133,16 @@ fn width(units: &[PlanUnitEligibility]) -> u64 { /// Gate 2: the same session with and without one automated fader lane. `builtin_bank_bytes`, /// `graph_incremental_plan_bytes` and `graph_session_plus_plan_bytes` each differ by exactly the /// lane's charge: the builder's byte report for its program and the program's shared allocation, -/// one automation slot per lane of its fader bank, and its offsets cell (D4). A cap one byte below -/// the admitted total refuses the session with `host.graph.resource.limit`, and the cap at the -/// total prepares it. +/// one automation slot per lane of its fader bank, and its offsets cell (D4). The charge is also +/// checked against the heap itself: the bytes the automated prepared host holds live, less the +/// plain one's, are exactly the charge, measured with the audited allocator's thread-scoped +/// counters (the compiled model is dropped inside the window). A cap one byte below the admitted total +/// refuses the session with `host.graph.resource.limit`, and the cap at the total prepares it. /// /// Test value: red if any term of the charge (the program, the bank slots or the offsets cell) /// escapes the graph rows, the caller's cap or (through `graph_session_plus_plan_bytes`) the C -/// ABI's replacement peak, or if a session without automation pays for it. +/// ABI's replacement peak, if the charged sizes disagree with what lowering actually retains, or +/// if a session without automation pays for it. #[test] fn a_fader_automation_is_charged_to_the_graph_plan_rows_and_the_cap() { let plain = bank_model(); @@ -131,8 +151,11 @@ fn a_fader_automation_is_charged_to_the_graph_plan_rows_and_the_cap() { let segments = controls::stored_segments(&entry); automated.automation = vec![entry]; - let base = prepare(&plain, u64::MAX).expect("the plain session prepares"); - let with = prepare(&automated, u64::MAX).expect("the automated session prepares"); + bench_support::alloc::assert_installed(); + // Warm the process statics first, so neither measured preparation pays for them. + drop(prepare_retained(&plain)); + let (base, base_retained) = prepare_retained(&plain); + let (with, with_retained) = prepare_retained(&automated); let sizes = fader_automation_sizes(); let lanes = width(&with.plan.unit_eligibility()); let program = ProgramBuilder::bytes(&[controls::FADER_CELL_RULE.describe(&segments)]); @@ -165,6 +188,12 @@ fn a_fader_automation_is_charged_to_the_graph_plan_rows_and_the_cap() { ); } + assert_eq!( + with_retained - base_retained, + charge, + "the heap the automation retains is its charge: {base_retained} -> {with_retained}" + ); + let total = admitted(&with.report); assert!( admitted(&base.report) < total - 1, @@ -193,7 +222,11 @@ fn signal(index: usize) -> f32 { /// `[left, right]` output bits over [`BLOCKS`] blocks, the source's two planes carrying the same /// PCM, and the plan's `[blocks rendered collapsed, chains armed with a prefix]`. fn render(model: &SessionModel) -> ([Vec; 2], [u64; 2]) { - let mut prepared = prepare(model, 100_000_000).expect("prepares"); + render_prepared(prepare(model, 100_000_000).expect("prepares")) +} + +/// [`render`] of an already prepared host. +fn render_prepared(mut prepared: PreparedHost) -> ([Vec; 2], [u64; 2]) { let mut planes = [Vec::new(), Vec::new()]; for block in 0..BLOCKS { let base = block * QUANTUM; @@ -277,3 +310,165 @@ fn a_left_only_fader_automation_keeps_a_mono_tracks_collapse() { ); } } + +/// The caller words of the D2 environment test: MXCSR on `x86_64`, FPCR on AArch64. +#[cfg(target_arch = "x86_64")] +mod word { + use lane::softfma::{MXCSR_DAZ, MXCSR_FTZ}; + + /// Flush-to-zero, denormals-are-zero and round-toward-zero (RC `11`, bits 13-14). + pub(super) fn hostile(saved: u32) -> u32 { + saved | MXCSR_FTZ | MXCSR_DAZ | 0x6000 + } +} + +/// FPCR `FZ` and `RMode` `11` (round toward zero); AArch64 has no separate DAZ bit. +#[cfg(target_arch = "aarch64")] +mod word { + use lane::fpenv::FPCR_FZ; + + pub(super) fn hostile(saved: u64) -> u64 { + saved | FPCR_FZ | (0b11 << 22) + } +} + +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +mod canonical_preparation { + use super::*; + use lane::fpenv::{ + FpControlWord, canonical_fp_control_word, read_fp_control_word, write_fp_control_word, + }; + + /// Writes the saved word back on drop, so a failed assertion leaves the thread as it was. + struct Restore(FpControlWord); + + impl Drop for Restore { + fn drop(&mut self) { + // SAFETY: `self.0` is the word this test read from this thread before it changed it, + // so it sets no reserved or `RES0` bit. + unsafe { write_fp_control_word(self.0) }; + } + } + + /// Runs `body` with the thread's control word set to `word`, then restores the saved word. + fn under(word: FpControlWord, body: impl FnOnce() -> T) -> T { + let _restore = Restore(read_fp_control_word()); + // SAFETY: `word` is the canonical word or `word::hostile` of a word read from this thread, + // which sets only FTZ, DAZ and RC (MXCSR) or FZ and RMode (FPCR): no reserved bit. + unsafe { write_fp_control_word(word) }; + body() + } + + /// A member dB whose composition with [`VCA_DB`] rounds differently under round-toward-zero, + /// and whose two compositions convert to different gains (asserted). + const VCA_MEMBER_DB: f32 = -0.01384; + /// The left offset of the fixture's VCA. + const VCA_DB: f32 = 1.0 / 3.0; + + /// Static faders of distinct dB on every lane, input trims and filters, no effect (an effect's + /// own preparation is outside this gate), a flat automated lane on track 2 and a moving + /// automated pair on track 5. One VCA reaches tracks 1, 2 and 5; track 1's left lane is + /// [`VCA_MEMBER_DB`]. + fn fixture() -> (SessionModel, Vec) { + let mut model = bank_model(); + model.console.pre_insert.clear(); + model.console.post_insert.clear(); + let mut decibels = Vec::new(); + for (index, track) in model.tracks.iter_mut().enumerate() { + let k = index as f32; + track.console.clear(); + track.fader.left_db = -0.37 - 1.13 * k; + track.fader.right_db = 0.71 - 0.89 * k; + track.builtins.left.trim_db = -1.3 + 0.21 * k; + track.builtins.left.hpf_hz = 37.3 + 3.1 * k; + track.builtins.right.lpf_hz = 15_013.0 - 271.0 * k; + decibels.extend([track.fader.left_db, track.fader.right_db]); + } + let flat = flat_fader(&model, 2, ParameterChannel::Left, -7.31); + let mut moving = flat_fader(&model, 5, ParameterChannel::Both, -3.17); + moving.segments[0].end_value = 2.5; + decibels.extend([-7.31, -3.17]); + model.automation = vec![flat, moving]; + model.tracks[1].fader.left_db = VCA_MEMBER_DB; + let members = [1, 2, 5] + .map(|track| model.tracks[track].id.clone()) + .to_vec(); + model.vcas = vec![session::Vca { + id: id("vca-a"), + fader: session::DualMonoFader { + left_db: VCA_DB, + right_db: -0.1, + left_mute: false, + right_mute: false, + }, + members, + }]; + (model, decibels) + } + + /// D2: a caller thread with FTZ, DAZ and round-toward-zero set prepares the plan a canonical + /// thread prepares. Each arm prepares under its word; both render under the canonical word, so + /// any difference is a prepared value. The fixture's dB-to-gain conversions and one VCA + /// composition round differently under round-toward-zero, by a step that reaches the gain + /// (both asserted), so the gate is not vacuous. + /// + /// Test value: red if preparation composes a strip's effective fader, or bakes a fader gain or + /// an input coefficient, outside `CanonicalFpEnv` (remove the guard around host-core's + /// `controls::effective`, in the builtins preparation body or in + /// `into_graph_artifact_with_banks`), so a flat automated lane and a static fader would take + /// the caller's rounding. + #[test] + fn a_hostile_caller_environment_prepares_the_canonical_faders() { + let canonical = canonical_fp_control_word(); + let hostile = word::hostile(canonical); + let (model, decibels) = fixture(); + + let moved = under(hostile, || { + decibels + .iter() + .filter(|&&db| { + let gain = builtins::checked_fader_gain(db).expect("in domain"); + let pinned = { + let _pin = lane::CanonicalFpEnv::enter(); + builtins::checked_fader_gain(db).expect("in domain") + }; + gain.to_bits() != pinned.to_bits() + }) + .count() + }); + assert!( + moved > 0, + "the gate is vacuous: no fixture fader converts differently under the hostile word" + ); + let offset = f64::from(VCA_DB); + let composed = session::vca_compose_db(VCA_MEMBER_DB, offset); + let rounded = under(hostile, || session::vca_compose_db(VCA_MEMBER_DB, offset)); + assert_ne!( + builtins::checked_fader_gain(composed).map(f32::to_bits), + builtins::checked_fader_gain(rounded).map(f32::to_bits), + "the gate is vacuous: the VCA composition's rounding does not reach the gain" + ); + + let prepared_canonical = under(canonical, || prepare(&model, 100_000_000)); + let prepared_hostile = under(hostile, || prepare(&model, 100_000_000)); + let render_arm = |prepared: Result| { + under(canonical, || render_prepared(prepared.expect("prepares")).0) + }; + let reference = render_arm(prepared_canonical); + let candidate = render_arm(prepared_hostile); + assert!( + reference[0].iter().any(|&bits| bits != 0), + "the fixture renders sound" + ); + for plane in 0..2 { + let first = reference[plane] + .iter() + .zip(&candidate[plane]) + .position(|(a, b)| a != b); + assert_eq!( + first, None, + "plane {plane}: the hostile caller's preparation renders the canonical bits" + ); + } + } +} From 6c8cf3a7f875f465cfeb3c456723a8a817ebf453 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 18:33:13 +0000 Subject: [PATCH 10/35] Admit the automation dependency in the builtins-compiler boundary policy #1646b gave builtins-compiler a normal automation dependency (preparation builds the cell program). The doc job's builtins policy pins the dependency list; add automation to the list and to the mutation fixture. Co-Authored-By: Claude Opus 5.5 --- scripts/check-builtins-policy.sh | 2 +- scripts/test-builtins-policy.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-builtins-policy.sh b/scripts/check-builtins-policy.sh index 4b510ecd9..5fa2eed38 100644 --- a/scripts/check-builtins-policy.sh +++ b/scripts/check-builtins-policy.sh @@ -14,7 +14,7 @@ expected_builtins=$'effect-contract\nengine\nlane\nmath' printf 'builtins policy failure: builtins dependency boundary changed\n' >&2; exit 1; } # #84 phase A: the compiler names lane::Backend instead of the deleted core enums. -expected_compiler=$'builtins\ncontrols\neffect-contract\nengine\ngraph\nlane\nrack\nrack-compiler\nsession\nsha2' +expected_compiler=$'automation\nbuiltins\ncontrols\neffect-contract\nengine\ngraph\nlane\nrack\nrack-compiler\nsession\nsha2' [[ "$(dependencies crates/builtins-compiler/Cargo.toml)" == "$expected_compiler" ]] || { printf 'builtins policy failure: builtins compiler dependency boundary changed\n' >&2; exit 1; } diff --git a/scripts/test-builtins-policy.sh b/scripts/test-builtins-policy.sh index 23c44e779..986508fd1 100644 --- a/scripts/test-builtins-policy.sh +++ b/scripts/test-builtins-policy.sh @@ -15,7 +15,7 @@ make_fixture() { "$fixture/crates/rack" printf '[workspace]\nmembers = []\n' >"$fixture/Cargo.toml" printf '[package]\nname = "builtins"\n[lib]\nname = "builtins"\n[dependencies]\nengine.workspace = true\neffect-contract.workspace = true\nlane.workspace = true\nmath.workspace = true\n' >"$fixture/crates/builtins/Cargo.toml" - printf '[package]\nname = "builtins-compiler"\n[lib]\nname = "builtins_compiler"\n[dependencies]\nbuiltins.workspace = true\ncontrols.workspace = true\nengine.workspace = true\neffect-contract.workspace = true\ngraph.workspace = true\nlane.workspace = true\nrack.workspace = true\nrack-compiler.workspace = true\nsession.workspace = true\nsha2.workspace = true\n' >"$fixture/crates/builtins-compiler/Cargo.toml" + printf '[package]\nname = "builtins-compiler"\n[lib]\nname = "builtins_compiler"\n[dependencies]\nautomation.workspace = true\nbuiltins.workspace = true\ncontrols.workspace = true\nengine.workspace = true\neffect-contract.workspace = true\ngraph.workspace = true\nlane.workspace = true\nrack.workspace = true\nrack-compiler.workspace = true\nsession.workspace = true\nsha2.workspace = true\n' >"$fixture/crates/builtins-compiler/Cargo.toml" printf '[package]\nname = "engine"\n' >"$fixture/crates/engine/Cargo.toml" printf '[package]\nname = "effect-contract"\n' >"$fixture/crates/effect-contract/Cargo.toml" printf '[package]\nname = "session"\n' >"$fixture/crates/session/Cargo.toml" From b93050289bc4f6d2fa63a736a9b7d9834971854c Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 18:33:48 +0000 Subject: [PATCH 11/35] Add placeholder TBD48: compose live level values inside CanonicalFpEnv Found by the batch 17 U1 fold: preparation now runs canonically, live fader and VCA compositions and conversions on the control thread do not. Co-Authored-By: Claude Opus 5.5 --- ...ues-inside-the-canonical-fp-environment.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md diff --git a/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md b/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md new file mode 100644 index 000000000..e5cc5abae --- /dev/null +++ b/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md @@ -0,0 +1,43 @@ +# Compose live level values inside the canonical floating-point environment + +Placeholder from the batch 17 U1 fold (controls coordinator, 2026-10-16). Root files it and gives +it a number. + +## Product outcome + +A live fader, VCA or trim move gives the same gain bits as a rebuild or a fresh preparation of the +same committed value, whatever floating-point environment the host's control thread runs in. Every +dB composition and dB-to-gain conversion that the control plane does for a live value runs inside +`lane::CanonicalFpEnv`, on both hosts, as preparation does since the U1 fold of #1646. + +## Context + +- The U1 fold of #1646 (batch 17) puts the builtins preparation body, both graph lowerings and + host-core's static `controls::effective` call inside `CanonicalFpEnv`. A test prepares under a + hostile caller word (FTZ, DAZ, round toward zero) and asserts canonical bits + (`canonical_preparation::a_hostile_caller_environment_prepares_the_canonical_faders`). +- Live values do not have that guard yet. The fold agent found these places, which compose or + convert on the control thread in the caller's environment: + - host-core `live_delta.rs` level records (`LevelDiff::level_records`: `EffectiveDiff` in + `prepare.rs`, `LocalDiff` in `live_delta.rs`), which compose the effective fader through + `controls::effective` / `LocalEffective`; + - the live setters that call `checked_fader_gain` when a record is written to a cell; + - `crates/host-core/src/control_provider.rs:1199` and `:1573` (`controls::effective` for the + catalog's derived rows; display only, but it must agree with render). +- README finding F11 of #1058 (preparation runs in the host's environment) is the same hazard. +- On a canonical host (every test host today) nothing differs, so no test is red today. + +## Decisions to make at filing + +- D1. Where the guard sits: once at the control-plane entry that classifies and commits a + transaction (`SessionState` in `crates/control-plane`), so every live composition and conversion + below it is covered on both hosts, or at each composition site. Prefer one entry guard (one core + rule, thin hosts); the guard is cheap (two control-register writes). +- D2. Gate: a live fader move, a live VCA move (#1649's offsets-cell write) and a hand move on an + automated fader (#1650b) made under a hostile caller word give the same gain bits as the same + value prepared canonically, on the C ABI and the browser. Red if the guard is removed. + +## Dependencies + +- #1646 (U1 fold: the preparation guard and its test pattern), #1649, #1650b (their live writes + join the gate). From 96fb3db406dc7c1e493ca4fce2fcf02fe18e04cb Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 18:38:24 +0000 Subject: [PATCH 12/35] Classify fader automation edits as rebuilds (#1648a) D1: an add, remove, segment or target edit of a rendered fader automation entry is a rebuild (LiveRebuild::Automation), checked after AutomationTarget and before changed controls; the offset is not compared. D3: an automated fader lane takes no FaderDb record on the full path, the fast path or the strip join. Parity row on both hosts; #1639 gap gate (a fader entry added after an unwritten smoothing change rebuilds and reads the new lengths). D4 docs. pcm_digest unchanged. Co-Authored-By: Claude Opus 5.5 --- ...assify-and-carry-fader-automation-edits.md | 79 +++++++ crates/capi/include/miso_engine_v1.h | 8 +- crates/capi/src/runtime/live_tests.rs | 87 +++++++- crates/controls/src/automation.rs | 98 ++++++++- crates/controls/src/lib.rs | 5 +- crates/host-core/src/live_delta.rs | 69 +++--- crates/host-core/src/prepare.rs | 27 ++- crates/host-core/tests/live_delta.rs | 204 +++++++++++++++++- .../host-core/tests/live_edit_classifier.rs | 1 + docs/CONTROL_PROTOCOL_SEMANTICS.md | 2 +- docs/C_ABI_V1_QUALIFICATION.md | 16 +- hosts/host-web/tests/control_parity.rs | 83 +++++++ 12 files changed, 630 insertions(+), 49 deletions(-) diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index 351961682..192c49bd9 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -388,3 +388,82 @@ other options. rows and the send mute only. The send gain (#1654) and the trim (#1658) take a hand offset, not `model_only`, from their own slices, and their rule is #1650's. Polarity stays "automation wins". No step here changes. + +## Decision record: #1648a (batch 17) + +Classification only (D1, D3's hook and predicate, D4, gate 1, the #1639 gap gate). Uncommitted on +`codex/controls-batch-17w` over `b84450183` for the coordinator. + +**What shipped.** + +- **`controls` (thick core).** `automation_row_renders` was already added by #1646b and is reused. + New: `automated_cell(model, &ControlAddress) -> bool` (a rendered entry on the same owner, + section and row whose lane drives the address's lane; a `both` address is automated when either + lane is), `automated_fader_lanes(model, strip) -> [bool; 2]` (the fader row through + `automated_cell`, early out on an empty table), and `rendered_automation_changed(current, next)` + (the rendered entries in table order, compared by `id`, `target` and `segments`, every `f32` by + bits; the offset is not compared, departure 3). `fader_automation` shares the private lane rule + `drives`. Exported from `crates/controls/src/lib.rs`. +- **D1.** `LiveRebuild::Automation` (`crates/host-core/src/prepare.rs`). In + `classify_live_delta_with_monitor` the new check is step 3, after step 2 (`AutomationTarget`) + and before the changed controls (now step 4); the later steps renumber to 5 and 6. Step 1 keeps + masking the whole table; its comment and the rustdoc steps and model-only paragraph now say that + steps 2 and 3 judge it. No control-plane edit: any classifier `Err` is `LiveCommit::Rebuild`. + The fast path (`classify_rows`) needs no edit: it only runs on transactions of `SetControl`s, + which leave the table as committed (R17-1 keeps that true under #1650b). +- **D3 hook.** `fader_records` gains `automated: impl FnOnce() -> [bool; 2]`, called only when a + lane's gain bits change; an automated lane's `gain_changed` is cleared, so it gives no `FaderDb` + and no error, and the mute records are unchanged. Both `LevelDiff` forms pass + `controls::automated_fader_lanes(self.rules.next.structure(), strip id)`: + `EffectiveDiff::level_records` (full path and `join_strips`, which then reads the strip as + `Same` and carries) and `LocalDiff::level_records` (fast path). +- **D4.** `crates/capi/include/miso_engine_v1.h` (live-edits paragraph), + `docs/CONTROL_PROTOCOL_SEMANTICS.md` (the "#1260" sentence), `docs/C_ABI_V1_QUALIFICATION.md` + (the model-only item narrowed, a new "Stored fader automation (#1648)" item). The text states + the carry ("the successor carries every owner"), which #1648b delivers in the same push. + +**Departures and readings.** + +- *Superseded cases moved, per the hazard.* `model_only_edits` in + `crates/host-core/tests/live_delta.rs` now upserts and removes an EQ band-1 gain entry + (`eq_automation(.., 4, Db)`), and `model_only_edits_are_live_with_no_records` runs on + `with_effects()` (it has the EQ insert; `two_tracks` has none). The C ABI twin upserts an entry + on the console EQ slot's band-1 gain (`Section::Console("eq")`, control 4, left, dB). +- *No static `left_db` case* (departure 5): a test superseded by #1650 is not written. D3's hook + is gated through the VCA move, the one edit in this slice that reaches an automated lane and + stays a no-`FaderDb` case after #1649 and #1650. +- *Mechanical, outside the path list:* `crates/host-core/tests/live_edit_classifier.rs` gains the + `LiveRebuild::Automation` arm of its exhaustive verdict match. + +**Gates and tests (all pass).** + +- Gate 1: `a_fader_automation_edit_is_a_rebuild` (`crates/host-core/tests/live_delta.rs`): add + (both lanes, one lane), remove, other segments, a zero's sign, retarget to the other track, + rename are `Automation`; an offset-only change is `Ok`; a mute entry added, changed and removed + is `Ok` with no records; a static change with an automation change is a rebuild; a `linear` fader + entry is `AutomationTarget`. +- D3: `an_automated_fader_lane_takes_no_fader_record` (same file): a VCA move under a left-lane + fader entry gives only the right lane's `FaderDb`; the left lane alone gives no record; the left + mute keeps its record; with `test-support`, `classify_live_edit` takes the fast path and returns + the same delta. +- Both hosts: `a_fader_automation_edit_rebuilds_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`, four rates): a fader entry replies `rebuild` with the + same bytes on both hosts and the same bits across the swap; a mute entry replies `model_only`. +- #1639 gap: `a_fader_entry_added_after_an_unwritten_smoothing_change_reads_the_new_lengths` + (`crates/capi/src/runtime/live_tests.rs`): the smoothing change is `model_only` and leaves the + prepared lengths; the fader entry is `rebuild`, and after adoption the block holds the new lengths. +- Gate 8: `./target/release/audit capi` `pcm_digest` `bffc21e857b5e134` (unchanged), 0 + violations, 0 allocations. + +**Checks run.** `cargo test --locked -p controls`; `-p host-core --all-features` (all binaries); +`-p control-plane`; `-p capi` (175 + integration binaries); `-p host-web --features +host-web/test-support --test control_parity` (22) and `--lib -- automation`; +`bash scripts/check-capi-abi.sh`; `cargo fmt --all`; `cargo clippy --locked -p controls -p +host-core -p capi -p host-web -p control-plane --all-targets --all-features -- -D warnings`; the +same crates with `cargo check --locked --all-targets` and no features. + +**For #1648b.** Until the carry lands, a fader automation edit rebuilds with today's join: the +fader stage carries its ramp as `Same` when no static level moved, but the cell event state does +not carry (`adopt_lane`), so the successor's cell starts at its prepared value. The D3 hook already +makes the join read a static change on an automated lane as `Same`, so #1648b's carry sees no +retarget record for it. diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index eeed91999..1a5bc1079 100644 --- a/crates/capi/include/miso_engine_v1.h +++ b/crates/capi/include/miso_engine_v1.h @@ -43,8 +43,12 @@ extern "C" { * whose automation rate is Block, on a console-slot entry or an insert, the parametric EQ's live * parameters included (applied through prepared targets); and a track effect's bypass, except the * delay's and the multiband compressor's, which rebuild. A transaction that changes only the - * session ID, a profile's id or the stored automation table commits with no replacement and no - * live value. Any other change takes the replacement path exactly as before: every submix strip + * session ID, a profile's id or a stored automation entry on a row that does not render yet + * commits with no replacement and no live value. A stored fader automation edit (an entry on a + * strip's fader_db added, removed, retargeted or given other segments) rebuilds the plan, and + * the successor carries every owner; a static fader value of a lane the session automates is that + * lane's fallback and gives no live value; the other rows stay model-only until they render. + * Any other change takes the replacement path exactly as before: every submix strip * value (its fader, its effects), any edit while the session declares a VCA, the mute of a track * that a follows_mute send reads, a fader outside its domain or effect parameters that * preparation refuses (refused as MISO_ENGINE_V1_COMPILE_REJECTED with the same diagnostic as diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 9a9e70324..c7ceaa236 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -2382,8 +2382,8 @@ fn model_only_edits(model: &SessionModel) -> [(&'static str, SessionEdit); 5] { ), ( "control smoothing", - // Before the automation upsert: on a session with a fader entry the edit writes the - // plan's jump-length cell and is `live` (#1639 D3). + // On a session with a fader entry the edit writes the plan's jump-length cell and is + // `live` (#1639 D3); the EQ entry below reads no length, so the session has none. // The first write turns the `default` arm into the explicit lengths (#1561 D4a). smoothing_edits(ControlSmoothing { mute_ms: 1.0, @@ -2393,15 +2393,18 @@ fn model_only_edits(model: &SessionModel) -> [(&'static str, SessionEdit); 5] { .remove(0), ), ( + // An entry on the console EQ's band-1 gain: a valid block-rate target whose row does + // not render until #1667, the last row to leave the mask. A fader entry is a rebuild + // (#1648 D1). "automation upsert", SessionEdit::UpsertAutomation { automation: session::Automation { - id: id("fader-ride"), + id: id("eq-ride"), target: session::ControlAddress { owner: session::Owner::Strip(model.tracks[0].id.clone()), - section: session::Section::Builtins, - control: 5, - lane: session::ParameterChannel::Both, + section: session::Section::Console(id("eq")), + control: 4, + lane: session::ParameterChannel::Left, }, segments: vec![session::AutomationSegment { shape: session::AutomationShape::Linear, @@ -8671,6 +8674,78 @@ fn a_control_smoothing_edit_on_an_automated_session_writes_the_jump_lengths_live } } +/// #1648a, the #1639 gap (batch 13b amendment, root ruling 2026-10-15). A playing session with no +/// stored automation takes a `control_smoothing` change: no entry reads the lengths, so the commit +/// is `model_only` and the plan keeps the prepared lengths. A later transaction adds a fader entry: +/// a fader automation edit is a rebuild (#1648 D1), and from the block that adopts the successor +/// the plan holds the committed lengths, which the successor seeds from its own model (#1639 D4). +/// +/// Test value: red if the fader entry commits without a rebuild (the cell would keep the prepared +/// lengths, which the earlier edit never wrote, so the new entry's jumps would ramp over stale +/// lengths), or if the successor seeds its cell from anything but its committed model. +#[test] +fn a_fader_entry_added_after_an_unwritten_smoothing_change_reads_the_new_lengths() { + let fast = ControlSmoothing { + mute_ms: 1.0, + fader_ms: 2.0, + pan_ms: 3.0, + }; + // The default table at 48 kHz: fader 20 ms, mute 10 ms, pan 20 ms. + let prepared = graph::GraphJumpLengths { + fader_samples: 960, + mute_samples: 480, + pan_samples: 960, + }; + let written = graph::GraphJumpLengths { + fader_samples: 96, + mute_samples: 48, + pan_samples: 144, + }; + let document = long_session(10, 48_000); + let mut rig = Rig::new(&document); + for _ in 0..2 { + rig.step(); + } + let bytes = rig.transaction(&smoothing_edits(fast)); + let (result, response) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK); + assert_eq!(edit_path_of(&response), protocol::EditPath::ModelOnly); + rig.step(); + assert_eq!( + graph::test_only_block_jump_lengths(), + Some(prepared), + "no entry reads the lengths: the cell is not written" + ); + let (revision, _, epoch, _) = rig.summary(); + let entry = parse_session_json(&with_fader_automation(&document)) + .expect("model") + .automation + .remove(0); + let bytes = rig.transaction(&[SessionEdit::UpsertAutomation { automation: entry }]); + let (result, response) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK); + assert_eq!( + edit_path_of(&response), + protocol::EditPath::Rebuild, + "a fader entry is a rebuild" + ); + let mut guard = 0; + while rig.summary().3 != 0 { + rig.step(); + guard += 1; + assert!(guard < 16, "the candidate is adopted"); + } + let (after, _, after_epoch, _) = rig.summary(); + assert_eq!(after, revision + 1, "one revision"); + assert_ne!(after_epoch, epoch, "the successor renders"); + rig.step(); + assert_eq!( + graph::test_only_block_jump_lengths(), + Some(written), + "the successor's cell holds the committed lengths" + ); +} + /// #1639 D4. One transaction adds a track (a structural edit, so a successor plan) and changes the /// three `control_smoothing` rows: the path is `rebuild`, and once render adopts the successor /// (the watermark covers the revision, the provider epoch moves) the plan holds the new lengths. diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index a202ef4c6..119efb7eb 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -402,8 +402,8 @@ pub fn fader_automation<'a>( { continue; } - for (index, lane) in [Lane::Left, Lane::Right].iter().enumerate() { - if (target.lane == *lane || target.lane == Lane::Both) && lanes[index].is_none() { + for (index, lane) in [Lane::Left, Lane::Right].into_iter().enumerate() { + if drives(target.lane, lane) && lanes[index].is_none() { lanes[index] = Some(automation); } } @@ -411,6 +411,100 @@ pub fn fader_automation<'a>( lanes } +/// Whether an entry on lane `entry` drives lane `lane` (`Left` or `Right`): a `both` entry drives +/// both lanes, a `left` or `right` entry its own. +fn drives(entry: Lane, lane: Lane) -> bool { + entry == lane || entry == Lane::Both +} + +/// Whether `model` holds a stored automation that renders ([`automation_row_renders`]) and drives +/// the cell at `address` (#1648 D3): an entry on the same owner, section and row whose lane drives +/// the address's lane. A `left` or `right` address names one cell; a `both` address names two, +/// and is automated when either lane is. This is the enumeration preparation compiles cells from +/// ([`fader_automation`] for the fader row): a cell exists exactly where this holds. +/// +/// A static value of an automated cell is the lane's fallback, not what the stage renders, so a +/// static edit of it gives no live record (#1648 D3). +#[must_use] +pub fn automated_cell(model: &SessionModel, address: &ControlAddress) -> bool { + model.automation.iter().any(|automation| { + let target = &automation.target; + automation_row_renders(target) + && target.owner == address.owner + && target.section == address.section + && target.control == address.control + && match address.lane { + Lane::Both => true, + lane => drives(target.lane, lane), + } + }) +} + +/// Whether `model` automates strip `strip`'s fader, per lane (`[left, right]`): [`automated_cell`] +/// at the strip's builtin `fader_db` row (#1648 D3). The live classifier and a successor's strip +/// join read it to give no `FaderDb` record to an automated lane. +#[must_use] +pub fn automated_fader_lanes(model: &SessionModel, strip: &StableId) -> [bool; 2] { + if model.automation.is_empty() { + return [false; 2]; + } + let mut address = ControlAddress { + owner: Owner::Strip(strip.clone()), + section: Section::Builtins, + control: FADER_DB, + lane: Lane::Left, + }; + let left = automated_cell(model, &address); + address.lane = Lane::Right; + [left, automated_cell(model, &address)] +} + +/// Whether the stored automation of the rows that render ([`automation_row_renders`]) differs +/// between `current` and `next` (#1648 D1): the rendered entries of each model, in table order, +/// compared by `id`, `target` and `segments`, every `f32` by bits (the canonical spelling keeps a +/// zero's sign, so this is the canonical-bytes comparison of those fields). An entry's hand offset +/// (#1650) is not compared: an offset-only change is a live write of its own (#1650 D5). An entry +/// whose target leaves or joins a rendered row is a difference. +/// +/// A difference needs a plan rebuild: a plan's cells are compiled from these entries at +/// preparation, so no live record can carry a changed curve. +#[must_use] +pub fn rendered_automation_changed(current: &SessionModel, next: &SessionModel) -> bool { + fn rendered(model: &SessionModel) -> impl Iterator { + model + .automation + .iter() + .filter(|automation| automation_row_renders(&automation.target)) + } + let (mut before, mut after) = (rendered(current), rendered(next)); + loop { + match (before.next(), after.next()) { + (None, None) => return false, + (Some(before), Some(after)) if same_curve(before, after) => {} + _ => return true, + } + } +} + +/// Whether two entries have the same `id`, `target` and `segments`, `f32` values by bits. +fn same_curve(before: &Automation, after: &Automation) -> bool { + before.id == after.id + && before.target == after.target + && before.segments.len() == after.segments.len() + && before + .segments + .iter() + .zip(&after.segments) + .all(|(before, after)| { + before.shape == after.shape + && before.start_sample == after.start_sample + && before.end_sample == after.end_sample + && before.start_value.to_bits() == after.start_value.to_bits() + && before.end_value.to_bits() == after.end_value.to_bits() + && before.unit == after.unit + }) +} + /// An entry's segments in the evaluator's type, in table order: the one conversion of a stored /// segment, which preparation and the order rule read. #[must_use] diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index 32048154d..daca9b654 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -45,8 +45,9 @@ mod vca; mod view; pub use automation::{ - CellRule, ControlDiagnostic, FADER_CELL_RULE, automation_diagnostics, automation_row_renders, - fader_automation, filter_statics_changed_under_automation, filter_under_automation, + CellRule, ControlDiagnostic, FADER_CELL_RULE, automated_cell, automated_fader_lanes, + automation_diagnostics, automation_row_renders, fader_automation, + filter_statics_changed_under_automation, filter_under_automation, rendered_automation_changed, stored_segments, }; pub use descriptor::{ diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index b893b6476..82c4ea3e7 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -182,7 +182,8 @@ pub struct LiveDelta<'a> { impl LiveDelta<'_> { /// Whether the delta holds no record of any kind (#1313 D2): a transaction that changed only - /// model-only fields (the session ID, a profile ID, the stored automation) or rewrote + /// model-only fields (the session ID, a profile ID, the stored automation of a row that does + /// not render yet) or rewrote /// identical values. The host reports such a commit as `model_only` and a delta with at least /// one record as `live`. /// @@ -262,16 +263,22 @@ pub use crate::prepare::LiveRebuild; /// automates either filter ([`controls::filter_statics_changed_under_automation`]: the order /// rule reads the static side, #1645) and [`controls::automation_diagnostics`] refuses one of /// `next`'s automations (decision 15 E1, #1335 D4, #1566 D6). An unchanged automation table -/// over unchanged activity and unchanged filter statics is never re-checked: it was prepared. An accepted -/// change stays live with no records, as #1260 D2 made it. -/// 3. With the structure equal, the two models list the same controls in the same order. For each +/// over unchanged activity and unchanged filter statics is never re-checked: it was prepared. +/// 3. [`LiveRebuild::Automation`] if the entries of the rows that render differ +/// ([`controls::rendered_automation_changed`], #1648 D1): an entry on such a row (today a +/// strip's fader, builtin row 5, [`controls::automation_row_renders`]) is added, removed, +/// retargeted or given other segments. A plan compiles its cells from these entries, so only a +/// rebuild can carry the change; the successor also seeds its jump-length cell from its own +/// model (#1639 D4). An entry's hand offset (#1650) is not compared. An accepted change on a +/// row that does not render yet stays live with no records, as #1260 D2 made it. +/// 4. With the structure equal, the two models list the same controls in the same order. For each /// control whose value bits differ ([`controls::changed_controls`]): a row that is /// rebuild-only is [`LiveRebuild::RebuildOnly`] with the row's reason; a row of family /// `Setting` gives no record; a live row marks its owner changed for its family. -/// 4. [`LiveRebuild::Domain`] if a changed effect's `params` do not resolve through +/// 5. [`LiveRebuild::Domain`] if a changed effect's `params` do not resolve through /// [`resolve_initial_values`] (the one function preparation uses), whether or not a row's value /// moved: an unknown parameter or a unit mismatch has no row. -/// 5. Each changed owner is lowered once per family (`lower`): a strip's input trim, polarity and +/// 6. Each changed owner is lowered once per family (`lower`): a strip's input trim, polarity and /// filters (#1556), a strip's fader and mute, a strip's pan or matrix, an effect instance's /// bypass and parameters, a send's gain, mute and matrix (#1557). An unchanged owner is /// never lowered. A lowering's domain refusal is [`LiveRebuild::Domain`]. @@ -284,9 +291,11 @@ pub use crate::prepare::LiveRebuild; /// record. The records come from the effective values only, never from a raw fader, mute or VCA /// field. /// -/// The session ID, the two profile IDs, the stored automation and `control_smoothing` are -/// model-only: no prepared plan reads them (#1260, #1365), so a delta that changes only them is -/// live with no records, once step 3 has accepted the automation's targets. The one exception is +/// The session ID, the two profile IDs, the stored automation of rows that do not render yet and +/// `control_smoothing` are model-only: no prepared plan reads them (#1260, #1365), so a delta that +/// changes only them is live with no records, once step 2 has accepted the automation's targets +/// and step 3 has found no rendered entry changed. Each rendering slice adds its row to +/// [`controls::automation_row_renders`], which moves that row's edits to step 3. The one exception is /// the plan's jump-length cell (#1639 D3): when the lengths in samples differ and `next` holds a /// stored automation whose jumps ramp over one of them ([`automation_jump_row`]), the delta /// carries [`LiveDelta::jump_lengths`] and is not empty. `control_smoothing`'s @@ -296,10 +305,13 @@ pub use crate::prepare::LiveRebuild; /// the key at that preparation only, so a later edit still governs only later transitions. The /// first such slice is a successor's carry retarget (#1277 D5): its fader, mute and matrix records /// are written to the successor's cells at preparation, with the lengths of the successor's own -/// model (and the transaction's per-edit lengths, #1394 D6). Masking -/// `automation` is correct only while no host renders stored automation: the first issue that -/// renders it (#1058) must remove it from the mask, or an automation edit would commit without -/// reaching the running plan. +/// model (and the transaction's per-edit lengths, #1394 D6). Step 1 masks the whole automation +/// table because steps 2 and 3 judge it, by row and by rendered curve. +/// +/// A static fader value of a lane that `next` automates ([`controls::automated_cell`]) gives no +/// `FaderDb` record (#1648 D3): the lane's cell renders the curve, and the static value is its +/// fallback. Since the record comes from the effective value, the rule covers a VCA move that +/// reaches the lane too. The lane's `Mute` records are unchanged. /// /// A record is emitted only when the value the render plane holds changes (`solo.rs`: never a /// redundant record): a `FaderDb` per lane whose effective gain bits change (one `Both` when both change to @@ -348,7 +360,7 @@ pub fn classify_live_delta<'a>( } /// [`classify_live_delta`] for a host that holds a monitor overlay (decision 17, C8, #1559 D6, -/// D7): the effective values of step 5 are those of `current` under `monitor.committed` and of +/// D7): the effective values of step 6 are those of `current` under `monitor.committed` and of /// `next` under `monitor.next`, so a solo's mutes and the closing of the sends that follow them /// are records like any other. A change of the overlay alone makes the effective values differ /// (the model is the same), and its records take the ramp of `monitor.ramp` unless a model edit @@ -373,8 +385,8 @@ pub fn classify_live_delta_with_monitor<'a>( masked.session_id = current.session_id.clone(); masked.render_profile.id = current.render_profile.id.clone(); masked.output_profile.id = current.output_profile.id.clone(); - // #1260 D2: correct only while no host renders stored automation (#1058). The first issue - // that renders it must drop this line. + // The stored automation is judged by steps 2 and 3, not by bytes here: its targets by their + // rows (step 2), the entries of the rows that render by their curves (step 3, #1648 D1). masked.automation = current.automation.clone(); match ( canonical_session_json(current), @@ -394,7 +406,12 @@ pub fn classify_live_delta_with_monitor<'a>( return Err(LiveRebuild::AutomationTarget); } - // Step 3. A VCA added or removed has no row in the diff, so the lists decide whether the + // Step 3 (#1648 D1). After step 2, so a refusable entry keeps its refusal's route. + if controls::rendered_automation_changed(current, next) { + return Err(LiveRebuild::Automation); + } + + // Step 4. A VCA added or removed has no row in the diff, so the lists decide whether the // effective values can differ, and a change of the overlay moves the effective mutes without // moving a row. let changes = controls::changed_controls(current, next, next.sample_rate_hz) @@ -420,10 +437,10 @@ pub fn classify_live_delta_with_monitor<'a>( } } - // Step 4. + // Step 5. refuse_unresolvable_params(current, next)?; - // Step 5. The effective values of both models, once (decision 17, C7). + // Step 6. The effective values of both models, once (decision 17, C7). let diff = accumulated .derived .then(|| EffectiveDiff::new(current, next, ramps, edit_ramps, monitor)); @@ -658,7 +675,7 @@ fn classify_rows<'a>( } } - // Step 4 of the full classifier, for the strips whose rows changed. + // Step 5 of the full classifier, for the strips whose rows changed. for item in &accumulated.work { let index = controls::strip_index(next, &item.strip)?; let (before, after) = (strip_at(current, index), view.strip(index)); @@ -795,7 +812,7 @@ impl RowOrder { } } -/// What the changed controls say, gathered by one body for both classifiers (step 3). +/// What the changed controls say, gathered by one body for both classifiers (step 4). #[derive(Default)] struct Changes { /// The changed controls of each strip, in the control list's order. @@ -879,7 +896,7 @@ impl Changes { } } -/// Step 5 for strips: the records of each `(index, changed controls)` item, in the order given +/// Step 6 for strips: the records of each `(index, changed controls)` item, in the order given /// (strip order), into `delta`. `index` counts [`SessionModel::strips`] in both models. fn lower_strips<'a>( current: &SessionModel, @@ -948,7 +965,7 @@ fn lower_strips<'a>( Ok(()) } -/// Step 5 for sends: the record of each route in `indices` (ascending positions in +/// Step 6 for sends: the record of each route in `indices` (ascending positions in /// `model.routes`) into a submix whose own rows changed (`routes`) or whose effective gate changed. #[expect( clippy::too_many_arguments, @@ -1245,10 +1262,12 @@ impl LevelDiff for LocalDiff<'_> { fn level_records(&self, strip: usize) -> Result<[Option; 4], OutOfDomain> { let strip = self.strip(strip).ok_or(OutOfDomain)?; + let change = self.change(strip); fader_records( &effective_fader(&strip.before), &effective_fader(&strip.after), - self.rules.lengths(&self.change(strip)), + self.rules.lengths(&change), + || controls::automated_fader_lanes(self.rules.next.structure(), change.id), ) } @@ -1447,7 +1466,7 @@ fn route_records<'a>( })) } -/// Step 4: a changed effect's `params` must resolve, as preparation resolves them. An unknown +/// Step 5: a changed effect's `params` must resolve, as preparation resolves them. An unknown /// parameter ID, a unit mismatch or a duplicate lane has no control row to move, yet a rebuild /// refuses it, so a live commit must not ack it. fn refuse_unresolvable_params( diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index dd8895ea7..13a8f751f 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -4196,6 +4196,12 @@ pub(crate) struct LevelLengths { /// the same value and the same length). A fader move on a lane that stays muted still gets its /// record, so the stage remembers the gain for a later unmute. /// +/// A lane that `automated` names (the lanes the post-change model automates, +/// [`controls::automated_fader_lanes`]) gets no `FaderDb` (#1648 D3): its cell renders the curve +/// and the static value is only its fallback. That is no error, so a successor's join reads the +/// strip as unchanged when nothing else moved, and its stage carries. Its `Mute` records are +/// unchanged. `automated` is called only when a lane's gain bits change. +/// /// # Errors /// /// [`OutOfDomain`] when either fader is outside the domain [`checked_fader_gain`] accepts. @@ -4203,6 +4209,7 @@ pub(crate) fn fader_records( before: &session::DualMonoFader, after: &session::DualMonoFader, lengths: LevelLengths, + automated: impl FnOnce() -> [bool; 2], ) -> Result<[Option; 4], OutOfDomain> { let gain = |db| { checked_fader_gain(db) @@ -4220,7 +4227,15 @@ pub(crate) fn fader_records( used += 1; }; let db_after = [after.left_db, after.right_db]; - let gain_changed = [0, 1].map(|lane| gains_before[lane] != gains_after[lane]); + let mut gain_changed = [0, 1].map(|lane| gains_before[lane] != gains_after[lane]); + // #1648 D3: an automated lane renders its curve; its static value is the fallback, so a change + // of it is no record. Asked only when a gain changed. + if gain_changed.contains(&true) { + let automated = automated(); + for lane in 0..2 { + gain_changed[lane] &= !automated[lane]; + } + } for (lanes, lane) in lane_records( gain_changed, db_after[0].to_bits() == db_after[1].to_bits() && lengths.fader[0] == lengths.fader[1], @@ -4528,10 +4543,12 @@ impl<'a> EffectiveDiff<'a> { before: usize, after: usize, ) -> Result<[Option; 4], OutOfDomain> { + let change = self.change(before, after); fader_records( &effective_fader(&self.before.strips[before]), &effective_fader(&self.after.strips[after]), - self.rules.lengths(&self.change(before, after)), + self.rules.lengths(&change), + || controls::automated_fader_lanes(self.rules.next.structure(), change.id), ) } @@ -4835,6 +4852,12 @@ pub enum LiveRebuild { /// rebuild's preparation refuses it with the row's code; the classifier only routes, so the /// refusal has one author and no live commit can ack it first. AutomationTarget, + /// The stored automation of a row that renders ([`controls::automation_row_renders`]) changes: + /// an entry is added, removed, retargeted or given other segments (#1648 D1, + /// [`controls::rendered_automation_changed`]). A plan compiles its cells from the entries at + /// preparation, so only a successor plan holds the new curve. An entry's hand offset (#1650) is + /// not part of this comparison. + Automation, } /// The one per-instance function that decides whether a difference between two lowered instances diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index e8484d53a..46ccf920c 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -1279,7 +1279,10 @@ fn a_static_filter_edit_that_crosses_an_automated_curve_needs_a_rebuild() { ); } -/// The model-only edits: #1260 D1's four and #1365 D4's `control_smoothing`. +/// The model-only edits: #1260 D1's four and #1365 D4's `control_smoothing`. The automation upsert +/// is an entry on `with_effects`' EQ band-1 gain, a valid block-rate target whose row does not +/// render until #1667, the last row to leave the mask (#1648 hazard): a fader entry is a rebuild +/// (#1648 D1). fn model_only_edits() -> [(&'static str, Edit); 5] { [ ("session id", |next| next.session_id = id("another-session")), @@ -1290,7 +1293,7 @@ fn model_only_edits() -> [(&'static str, Edit); 5] { next.output_profile.id = id("another-output-profile"); }), ("automation upsert", |next| { - let ride = fader_ride(next); + let ride = eq_automation(next, 4, ParameterUnit::Db); next.automation.push(ride); }), ("control smoothing", |next| { @@ -1307,7 +1310,7 @@ fn model_only_edits() -> [(&'static str, Edit); 5] { #[test] fn model_only_edits_are_live_with_no_records() { use BuiltinLaneSelector::Left; - let current = two_tracks(); + let current = with_effects(); assert!(current.automation.is_empty()); for (name, edit) in model_only_edits() { assert_eq!(classify(¤t, edit), Ok(Vec::new()), "{name}"); @@ -1338,15 +1341,206 @@ fn model_only_edits_are_live_with_no_records() { None )]) ); - // Removing stored automation is model-only too. + // Removing stored automation of a row that does not render is model-only too. let mut automated = current.clone(); - automated.automation.push(fader_ride(¤t)); + automated + .automation + .push(eq_automation(¤t, 4, ParameterUnit::Db)); assert_eq!( classify(&automated, |next| next.automation.clear()), Ok(Vec::new()) ); } +/// `fader_ride` on the first track's left lane only. +fn left_fader_ride(model: &SessionModel) -> Automation { + let mut ride = fader_ride(model); + ride.target.lane = ParameterChannel::Left; + ride +} + +/// #1648 gate 1 (as amended by the batch 17 spec check, departure 5). A fader entry (builtin row +/// 5, a row that renders) added, removed, given other segments, retargeted or renamed is +/// `Automation`; a change of its hand offset alone is not (departure 3). A mute entry (row 6, not +/// rendered yet) is live with no records. A static change together with an automation change is a +/// rebuild. A fader entry in unit `linear` keeps `AutomationTarget` (#1645 D5), so the row rule +/// runs before D1's check. +/// +/// Test value: red if the mask still hides fader entries (an edit is acked and never heard), if it +/// hides nothing (a mute entry rebuilds), if the comparison reads the offset (a hand move would +/// rebuild), or if D1's check runs before the refusal route. +#[test] +fn a_fader_automation_edit_is_a_rebuild() { + let current = two_tracks(); + let automated = normalized(&edited(¤t, |model| { + let ride = fader_ride(model); + model.automation.push(ride); + })); + let cases: [(&str, &SessionModel, Edit); 7] = [ + ("added", ¤t, |next| { + let ride = fader_ride(next); + next.automation.push(ride); + }), + ("added on one lane", ¤t, |next| { + let ride = left_fader_ride(next); + next.automation.push(ride); + }), + ("removed", &automated, |next| next.automation.clear()), + ("other segments", &automated, |next| { + next.automation[0].segments[0].end_value = -6.0; + }), + ("a zero's sign", &automated, |next| { + next.automation[0].segments[0].start_value = -0.0; + }), + ("retargeted to the other track", &automated, |next| { + next.automation[0].target.owner = Owner::Strip(next.tracks[1].id.clone()); + }), + ("renamed", &automated, |next| { + next.automation[0].id = id("another-ride"); + }), + ]; + for (name, base, edit) in cases { + assert_eq!(classify(base, edit), Err(LiveRebuild::Automation), "{name}"); + } + // The hand offset is not part of the curve (#1650 D5 makes it a live write of its own). + assert!( + classify(&automated, |next| { + next.automation[0].offset.left_db = 2.0; + }) + .is_ok(), + "an offset-only change is not a rebuild" + ); + // A mute entry's row does not render yet: added, changed and removed, it is live with no + // records. + let mute_ride = |model: &SessionModel| { + let mut ride = fader_ride(model); + ride.id = id("mute-ride"); + ride.target.control = 6; + ride.segments[0] = AutomationSegment { + shape: AutomationShape::Step, + start_sample: 0, + end_sample: 960, + start_value: 1.0, + end_value: 1.0, + unit: ParameterUnit::Linear, + }; + ride + }; + let muted = normalized(&edited(¤t, |model| { + let ride = mute_ride(model); + model.automation.push(ride); + })); + assert_eq!( + classify(¤t, |next| { + let ride = mute_ride(next); + next.automation.push(ride); + }), + Ok(Vec::new()), + "a mute entry added" + ); + assert_eq!( + classify(&muted, |next| next.automation[0].segments[0].end_sample = + 1_920), + Ok(Vec::new()), + "a mute entry changed" + ); + assert_eq!( + classify(&muted, |next| next.automation.clear()), + Ok(Vec::new()), + "a mute entry removed" + ); + // A static change in the same transaction as an automation change is a rebuild. + assert!( + classify(¤t, |next| { + next.tracks[0].fader.left_db = -6.0; + let ride = fader_ride(next); + next.automation.push(ride); + }) + .is_err(), + "a static change with an automation change" + ); + // A fader entry the row refuses keeps the refusal's route (#1645 D5). + assert_eq!( + classify(¤t, |next| { + let mut ride = fader_ride(next); + ride.segments[0].unit = ParameterUnit::Linear; + next.automation.push(ride); + }), + Err(LiveRebuild::AutomationTarget), + "a fader entry in unit linear" + ); +} + +/// #1648 D3, the hook in the shared level lowering: a lane that `next` automates gets no +/// `FaderDb`, by the full classifier and by the fast path alike; the other lane and the mute keep +/// their records. A VCA move is the case that reaches an automated lane in this slice (the own +/// fader's hand move is #1650's offset write). +/// +/// Test value: red if either classifier path still writes a static `FaderDb` onto an automated +/// lane (a record that fights the curve), if the suppression drops the other lane's record or a +/// mute record, or if a suppressed lane turns into a refusal. +#[test] +fn an_automated_fader_lane_takes_no_fader_record() { + use BuiltinLaneSelector::{Left, Right}; + let current = normalized(&edited(&with_vca(0.0, 0.0, [false; 2]), |model| { + let ride = left_fader_ride(model); + model.automation.push(ride); + })); + let first = track_id(¤t, 0); + let both = |next: &mut SessionModel| { + next.vcas[0].fader.left_db = -6.0; + next.vcas[0].fader.right_db = -6.0; + }; + assert_eq!( + classify(¤t, both), + Ok(vec![(first.clone(), vec![fader_db(Right, -6.0, 0)], None)]), + "the automated left lane takes no record" + ); + assert_eq!( + classify(¤t, |next| next.vcas[0].fader.left_db = -6.0), + Ok(Vec::new()), + "a move of the automated lane alone is no record" + ); + assert_eq!( + classify(¤t, |next| { + next.vcas[0].fader.left_db = -6.0; + next.vcas[0].fader.left_mute = true; + }), + Ok(vec![(first, vec![mute(Left, true, 0)], None)]), + "the automated lane's mute keeps its record" + ); + #[cfg(feature = "test-support")] + { + let next = edited(¤t, both); + let rows = [ControlAddress { + owner: Owner::Vca(id("vca")), + section: Section::Own, + control: 1, + lane: ParameterChannel::Both, + }]; + let full = classify_live_delta(¤t, &next, STEP, &EditRamps::new()); + let _ = host_core::take_fast_classification_count(); + let fast = host_core::classify_live_edit( + ¤t, + &next, + STEP, + &EditRamps::new(), + host_core::MonitorBase::NONE, + Some(&rows), + ); + assert_eq!( + host_core::take_fast_classification_count(), + 1, + "the VCA move takes the fast path" + ); + let fast = fast.map(|mut delta| { + delta.derived_owners = None; + delta + }); + assert_eq!(fast, full, "the fast path suppresses the same lane"); + } +} + /// #1260 gate 1. Red if a profile field that preparation reads is masked with its profile's ID. #[test] fn an_output_profile_channel_change_is_structural() { diff --git a/crates/host-core/tests/live_edit_classifier.rs b/crates/host-core/tests/live_edit_classifier.rs index d87ddfcdb..1d5041c2a 100644 --- a/crates/host-core/tests/live_edit_classifier.rs +++ b/crates/host-core/tests/live_edit_classifier.rs @@ -215,6 +215,7 @@ fn a_random_transaction_classifies_as_the_full_classifier_does() { Err(LiveRebuild::Structure) => "structure", Err(LiveRebuild::Domain) => "domain", Err(LiveRebuild::AutomationTarget) => "automation", + Err(LiveRebuild::Automation) => "automation curve", }); } for row in [ diff --git a/docs/CONTROL_PROTOCOL_SEMANTICS.md b/docs/CONTROL_PROTOCOL_SEMANTICS.md index 67f86dca7..da20a6db2 100644 --- a/docs/CONTROL_PROTOCOL_SEMANTICS.md +++ b/docs/CONTROL_PROTOCOL_SEMANTICS.md @@ -18,7 +18,7 @@ All v1 operations are absolute and idempotent. A new logical command has a nonze There is one way to make a change at a sample time: a stored automation edit (`SessionEdit` opcodes `0600`-`0603`), which the engine renders from the session's stored automation table (#1058). The command `AUTOMATION_ENQUEUE` (`0006`), which once admitted a batch of timed records to a queue, is **retired** (#1627, under the owner ruling in `rulings/engine-footprint-2026-09-28.md`: sample-timed delivery had no product consumer, and a host that admitted a batch acked work that no PCM reached). A frame under message ID `0006` is refused at decode as `UNSUPPORTED_MESSAGE`, before the revision check, so no revision, event, queue or replay entry changes and no ack can precede a drop; no endpoint lists `0006`, flag bit 5 or the capability fields that described its admission (9, 12, 19 and 20), and statuses 15 (`TIME_IN_PAST`) and 16 (`AUTOMATION_ORDER`) are retired with it. Every retired code is refused, never reallocated and never renumbered (see the [registry](CONTROL_PROTOCOL_REGISTRY.md)). The `Step`, `Linear` and `Exponential` span kinds and the `Sample` automation rate are retired with it (#1636, `EFFECT_CONTRACT_V1.md`, "V1 runtime automation"). -**Delivery history (v1).** The delivery of the command was planned by #140, descoped by the owner ruling in `rulings/engine-footprint-2026-09-28.md`, and its unwired partial implementation (the #460 delivery service, its controller facade and the host-core builtin-batch and scalar-point endpoints) was deleted by #1056. #1315 then refused the command `UNAVAILABLE` on both hosts behind a provider feature switch that nothing set; #1627 retired the command and removed the switch. Two outcomes have their own owners instead. Rendering the session's stored automation table is #1058. Live value edits on the C ABI are delivered by #1053: a committed `SESSION_TRANSACTION_APPLY` whose delta is only live values is lowered onto the running plan's live-control lanes, with the same response and events as before. The live values are a track's fader, mute and pan or matrix values (#1257); a changed value of a track effect parameter whose `automation_rate` is `Block`, on a console-slot entry or an insert (#1264), the parametric EQ's live parameters included, which ride prepared targets (#1265); and a track effect's bypass, except the delay's and the multiband compressor's, which rebuild (#1266). A transaction that changes only the session ID, a profile's id or the stored automation table commits with no replacement and no live value (#1260). Every other transaction still replaces the plan, including every submix strip value (#1053 G1), any edit while a VCA is declared (G3) and a mute change on a track that a `follows_mute` route reads (G2) (`C_ABI_V1_QUALIFICATION.md`, "Value-only track edits on the running plan" and the sections after it). +**Delivery history (v1).** The delivery of the command was planned by #140, descoped by the owner ruling in `rulings/engine-footprint-2026-09-28.md`, and its unwired partial implementation (the #460 delivery service, its controller facade and the host-core builtin-batch and scalar-point endpoints) was deleted by #1056. #1315 then refused the command `UNAVAILABLE` on both hosts behind a provider feature switch that nothing set; #1627 retired the command and removed the switch. Two outcomes have their own owners instead. Rendering the session's stored automation table is #1058. Live value edits on the C ABI are delivered by #1053: a committed `SESSION_TRANSACTION_APPLY` whose delta is only live values is lowered onto the running plan's live-control lanes, with the same response and events as before. The live values are a track's fader, mute and pan or matrix values (#1257); a changed value of a track effect parameter whose `automation_rate` is `Block`, on a console-slot entry or an insert (#1264), the parametric EQ's live parameters included, which ride prepared targets (#1265); and a track effect's bypass, except the delay's and the multiband compressor's, which rebuild (#1266). A transaction that changes only the session ID, a profile's id or a stored automation entry on a row that does not render yet commits with no replacement and no live value (#1260). A stored fader automation edit (an entry on a strip's `fader_db` added, removed, retargeted or given other segments) is a rebuild that carries every owner (#1648); a static fader value of a lane the session automates is that lane's fallback and gives no live record (#1648 D3); every other row stays model-only until its rendering slice adds it. Every other transaction still replaces the plan, including every submix strip value (#1053 G1), any edit while a VCA is declared (G3) and a mute change on a track that a `follows_mute` route reads (G2) (`C_ABI_V1_QUALIFICATION.md`, "Value-only track edits on the running plan" and the sections after it). **Sources across a structural transaction.** A committed transaction that replaces the plan does not reset the sources it leaves unchanged. On the C ABI (#1273) such a source keeps its ring, generation and read position across the plan swap; source submissions and seeks address the newest committed session from the commit on, so a host keeps feeding the source contiguously and must not reseek it. PCM is never part of the protocol, so the transaction acknowledges no PCM: PCM already accepted for a source the transaction removed is discarded with its plan, and later submissions for it are refused as `source.id.unknown`. An added source starts at generation 1, frame 0, until the host seeks it; a source whose declaration the transaction changed restarts the same way, in a new ring at generation 1, frame 0, with the PCM accepted for it before the commit discarded with its plan, so the host restarts its feed. diff --git a/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index 612e45ed4..ae2ecbe2c 100644 --- a/docs/C_ABI_V1_QUALIFICATION.md +++ b/docs/C_ABI_V1_QUALIFICATION.md @@ -620,14 +620,22 @@ model's delta (`host_core::classify_live_delta`, #1053 D1), never by its opcodes retargets its fader, mute and matrix over the same ones (#1277 D5, #1394 D6). Editing `control_smoothing` itself is a model-only delta (#1365): see the next item. - **Model-only (#1260, #1365).** A delta that changes only fields no prepared plan reads -- the - session ID, the render profile's `id`, the output profile's `id`, the stored `automation` table - and `control_smoothing` (`SET_CONTROL` of the session rows `fader_ms`, `mute_ms` and `pan_ms`; + session ID, the render profile's `id`, the output profile's `id`, the stored `automation` + entries of rows that do not render yet, and `control_smoothing` (`SET_CONTROL` of the session rows `fader_ms`, `mute_ms` and `pan_ms`; only a document replace writes the default table back, #1561) -- is live with no records: it commits through the same admission and protocol predicates, emits the same response and `SESSION_COMMITTED`, and the running plan, its source rings and its effect state continue untouched. It may ride with fader, mute and pan edits, and the records of the same transaction ramp over the new `control_smoothing` lengths; a - ramp already in flight keeps the length it started with. The automation term holds only while no - host renders stored automation (#1058); every other profile field stays structural. + ramp already in flight keeps the length it started with. Every other profile field stays + structural. +- **Stored fader automation (#1648).** An edit of a stored automation entry on a row that renders + (today a strip's `fader_db`, `controls::automation_row_renders`) -- an entry added, removed, + retargeted or given other segments -- rebuilds the plan (`LiveRebuild::Automation`, reported + `rebuild`), and the successor carries every owner and seeds its jump-length cell from its own + model. An entry's hand offset (#1650) is not part of that comparison. A static fader value of a + lane the session automates is that lane's fallback: it gives no `FaderDb` record, whether an own + edit or a VCA move reaches it, and its mute records are unchanged. Every other row stays + model-only until its rendering slice adds it. - **Live effect parameters (#1264, #1265).** A track's console-slot entry or insert whose `params` change only in live parameters, parametric EQ values included, is live too; see "Value-only effect parameter edits" and "Value-only parametric EQ edits" below. So is a diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 95fcc4319..cd55beb02 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -2760,6 +2760,89 @@ fn both_hosts_refuse_a_source_of_more_than_two_channels_alike() { } } +/// #1648a (D1): adding stored fader automation (builtin row 5, a row that renders) is a rebuild +/// on both hosts, with the same reply bytes, and both hosts render the same bits across the swap; +/// an entry on a row that does not render yet (the mute, row 6) commits `model_only` on both. +/// Both hosts call the one shared classifier, so neither has its own rule for automation edits. +/// +/// Test value: red if either host acks a fader automation edit as `model_only` (an edit that is +/// never heard), if the two hosts answer it differently, or if the browser's rebuild renders other +/// bits than the C ABI's. +#[test] +fn a_fader_automation_edit_rebuilds_alike_on_both_hosts() { + let entry = |name: &str, control: u32, segment: session::AutomationSegment| { + SessionEdit::UpsertAutomation { + automation: session::Automation { + id: id(name), + target: address( + Owner::Strip(id(T1.id)), + Section::Builtins, + control, + ParameterChannel::Both, + ), + segments: vec![segment], + offset: session::AutomationOffset::default(), + }, + } + }; + let fader = entry( + "t1-fader-ride", + 5, + session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: 960, + start_value: 0.0, + end_value: -6.0, + unit: session::ParameterUnit::Db, + }, + ); + let mute = entry( + "t1-mute-ride", + 6, + session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: 0, + end_sample: 960, + start_value: 0.0, + end_value: 0.0, + unit: session::ParameterUnit::Linear, + }, + ); + for rate in RATES { + let model = model_at(rate); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + same_blocks(&mut browser, &mut c_abi, 0..3, "before the edit"); + let (b, b_reply) = browser.transaction_reply(std::slice::from_ref(&fader)); + let (c, c_reply) = c_abi.transaction(std::slice::from_ref(&fader)); + assert_eq!(b, c, "{rate} Hz: one verdict for the fader entry"); + assert_eq!(b_reply, c_reply, "{rate} Hz: one reply"); + assert_eq!( + b, + Verdict::Applied { + path: EditPath::Rebuild, + revision: model.revision + 1 + }, + "{rate} Hz: a fader entry rebuilds" + ); + same_blocks(&mut browser, &mut c_abi, 3..8, "across the swap"); + let (b, b_reply) = browser.transaction_reply(std::slice::from_ref(&mute)); + let (c, c_reply) = c_abi.transaction(std::slice::from_ref(&mute)); + assert_eq!(b, c, "{rate} Hz: one verdict for the mute entry"); + assert_eq!(b_reply, c_reply, "{rate} Hz: one reply"); + assert_eq!( + b, + Verdict::Applied { + path: EditPath::ModelOnly, + revision: model.revision + 2 + }, + "{rate} Hz: a mute entry is model-only" + ); + same_blocks(&mut browser, &mut c_abi, 8..10, "after the mute entry"); + } +} + /// #1631. A `matrix_mode` switch under a stored pan entry is refused alike by both hosts: the same /// refusal and the same diagnostic (`control.inactive` at the entry's path, the row rule's /// own, through the rebuild route), and nothing commits -- the same expected revision still From 1fbb0bcc491536af421382fad87ae3d1a7f64a78 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 18:58:28 +0000 Subject: [PATCH 13/35] Render moving stored fader automation (#1647b) One event driver per fader stage turns each automated lane's cell events (grid, jump, held, set) into timed fader operations at node time timeline(r + sum P - a(n)), composed with the lane's held S. The bank renders through process_with only when it has automation; the fused fader-matrix bank fuses with operations. Restart Set for a lane a successor adds; seeks set the lane exactly on both hosts. The scalar oracle renders the same events through the same driver (R17-4), on the ramped fader. Realtime policy 155 -> 159 regions; lane slot 168 -> 192 B (measured, charged). pcm_digest unchanged. The fader row's docs now say it renders. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/author-session/SKILL.md | 5 +- ...7-render-moving-stored-fader-automation.md | 113 +++ crates/builtins-compiler/src/lib.rs | 785 +++++++++++++++-- crates/capi/src/runtime/tests.rs | 138 +++ crates/graph/src/lib.rs | 54 +- crates/graph/src/runtime.rs | 16 +- .../tests/stored_fader_automation.rs | 834 +++++++++++++++++- docs/REALTIME_MEMORY.md | 2 +- docs/SESSION_SCHEMA_V1.md | 16 +- hosts/host-web/src/tests.rs | 71 ++ 10 files changed, 1942 insertions(+), 92 deletions(-) diff --git a/.claude/skills/author-session/SKILL.md b/.claude/skills/author-session/SKILL.md index 39d1c4581..af765ea7a 100644 --- a/.claude/skills/author-session/SKILL.md +++ b/.claude/skills/author-session/SKILL.md @@ -121,8 +121,9 @@ validator checks only that the owner, slot or insert exists; whether the row is rebuild-only like `delay_samples` 11 or an EQ band's `enabled`, not derived, not a monitor or setting row, and not a send's `follows_mute` (route row 7) or a strip's `matrix_mode` (own row 1): live controls that a ruling keeps out of automation, #1632), takes the lane, unit and values is `control.address.unknown`, `control.automation.target`, `control.inactive`, `control.lane` or `control.domain` at prepare-effects. A -parameter does not have to be declared on the strip. Stored automation is inert today: it authors -and round-trips, and renders nothing. +parameter does not have to be declared on the strip. Stored automation renders on the fader row +(builtins 5) today, on every host (#1647); an entry on any other row authors and round-trips and +renders nothing yet. Every automation entry ends with a required `offset` record after `segments`: `"offset": {"left_db": 0.0, "right_db": 0.0}`. It is the dB a hand move adds to the curve of each diff --git a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md index de14e6bcd..892892354 100644 --- a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md @@ -497,3 +497,116 @@ automation). Now only automated sessions pay: `lane_slot_bytes` 120 -> 168 (x86- bound-node processor trait and the graph-compiler install, or a per-node arrival at binding). - A plan with no source set or no jump-length cell gives `span`/`fader_jump` `None`; every host-core plan has both. + +## Decision record: #1647b (batch 17) + +The events on the fader (D1.1-D1.5, D2, D3, D4; gates 1-6). The held-ramp obligation (gate 8) +is #1647c. + +**Shipped.** + +- **One event driver (D1.3-D1.5, R17-4).** `FaderEventDriver` (`crates/builtins-compiler`): per + block, one fixed cursor per automated channel (at most 16, two per bank lane) holding its + `CellEvents` (`SeekMode::Exact` from `controls::FADER_CELL_RULE`, the block's fader jump length) + and the next event; it is the stage's `FaderOperationSource`, so the merge by offset needs no + buffer. Each event's gain is `checked_fader_gain(vca_compose_db(value, S))` with the held `S` + (#1646 D1): `Grid`/`Jump` -> `RetargetGain` over the event's ramp, a held event -> + `RememberGain`, `Set` -> `SetGain`. A refused conversion or sink push keeps the target, adds one + to the lane's `refused_events` and fails a `debug_assert` (unreachable). `finish` consumes any + event a stopped stage did not pull, so the cell's state still advances. +- **Fader bank.** `process` renders through `BuiltinFaderBank::process_with(driver)` when the bank + has automation, else the old `process` (a session without automation runs the same code). The + fused fader-matrix bank fuses through `try_process_settled_with_matrix_and_operations`, so it + fuses only when no fader event falls in the block (the matrix half declines a ramping matrix); + its fallback is `process_with` then the matrix. +- **D1.1.** Each lane builds its `NodeSpan` from the span #1647a stores (`start`, the timeline step + as `(offset, timeline)`); a span whose `start + frames` (or step `timeline + frames`) leaves the + `i64` range is not rendered that block (the lane holds; only at the saturated clock edges). + No span or no jump length (a plan without a timeline or jump-length cell): the lane holds. +- **D1.2.** The drain marks each lane and channel a mute record with a nonzero window started a + held ramp on (`drain_fader_controls_marking`); right after the drain, at that block entry only, + the cell gets `hold_until(node time of the ramp's last sample)` from the stage's + `ramp_remaining` (batch 13b constraint 1). The oracle's drain does the same. +- **D2, departure 4.** `LaneFaderAutomation::fresh`: a lane's first block, with no timeline step, + gets the step `(0, start)` when `value_at(start)` differs from the seeded target, so a fresh plan + emits no `Set` and an added or restarted lane is set to its curve. When the first block has a + timeline step, that step lands instead (the samples before it precede the seek on the node). +- **D3.** A seek is the span's step: `SeekMode::Exact` lands a `Set` at its offset. +- **Scalar oracle (R17-4).** `GraphRuntimeProcessor` gains defaulted `set_input_arrival(u64)` and + `set_block_time(&GraphBlockTime)`, `GraphRuntimeSplitPairProcessor` a defaulted + `set_block_time`. `execute_op` takes the block's `GraphBlockTime` and calls `set_block_time` on a + bound node (and on a split pair's fader half) right before it processes. + `PreparedBuiltinsGraphArtifact::install_builtin_input_arrivals` also gives each builtin binding + its node's `a(n)` through `GraphNodeBinding::set_input_arrival`, with the one lookup + `graph::floored_input_arrival` the bank install now uses too (the smaller core-consistent path: + the same install, no per-node arrival at binding, no `GraphBindingBlock` field). An automated + strip with no live control binds `AutomatedFaderProcessor` (the ramped fader, #1646b departure + 5); `FaderProcessor` is the plain unramped owner again. Both fader owners render through + `render_oracle_fader` (the same driver at lane 0). The scalar pair never fuses an automated + fader and the split pair never defers one; both forward the block time. +- **Probe (gate 3).** `builtins_compiler::test_only_take_fader_events()`: each event the driver + gave a stage, with its render sample (fixed array, thread-local, test-support). +- **D4.** `docs/SESSION_SCHEMA_V1.md` (the fader row renders, every other row inert), + `.claude/skills/author-session/SKILL.md` (same), `docs/REALTIME_MEMORY.md:13` (a compiled table in + the plan, no queue). + +**Departures.** + +1. **Counters live in the lane slot**, not as `FaderBankProcessor` fields (departure 6): + `LaneFaderAutomation::{refused_events, operations_stopped}` (a bank's stop counts on each of its + automated lanes). A bank field would grow `size_of::()` for every session + and move `fixtures/graph/v1/direct-route.resources.json` (#1647a's finding); the slot is paid + only by automated sessions. They are read off render; no qualification row is added. +2. **The oracle's automated fader never fuses or defers** (the bank fuses when no event falls in + the block). Both are exact; the oracle has no fused-with-operations form, and the parity gate + below proves the bits equal. +3. **Gate 4's restart case.** On the C ABI the raised-floor plain seek's discontinuity successor + lands the seek through the span's step in its first block (its timeline history carries the + seek), so the D2 restart `Set` is not what sets that lane; the restart path is gated by a + successor that adds an automated lane mid-curve (below). +4. `S` excludes the #1650 hand offset until #1650 folds it into `offsets_sum`; render already + composes with that field. + +**Gates** (x86-64 AVX2 host). + +- Gate 1: `a_fader_fade_reaches_the_curve_at_each_completion_sample` (completion samples exact; + every sample equals the stage driven directly with the grid's retargets). +- Gate 2: `a_fader_step_jumps_at_its_exact_sample` (smoothing 0 and the default table), + `a_written_jump_length_reaches_the_next_jump_not_the_one_in_flight` (480 then 960, bit-identical + to the stage driven directly). +- Gate 3: `a_fader_step_meets_its_source_frame_through_a_latent_insert` (events at `4,800` and + `4,800 + λ`; output equals the 0 dB session before and the -6 dB session from the step's output + sample). Red on the revert of `a(n)` in `take_block_time` (checked once). +- Gate 4: `a_session_seek_sets_the_fader_lane_exactly` (host-core, one `Set` per channel at `r`, + exact), `a_session_seek_sets_an_automated_fader_exactly_on_the_c_abi` (running plan and raised + floor), `a_session_seek_sets_an_automated_fader_exactly_in_the_browser` + (`miso_engine_web_v1_session_seek`). +- D2: `a_lane_a_successor_adds_starts_at_its_curve` (Simd8, Simd4; red with the restart `Set` + disabled, checked once). +- Gate 5: `the_fader_events_do_not_depend_on_the_quantum` (128 against 100, with and without a seek). +- Gate 6: `rendering_fader_events_allocates_nothing` (1,000 blocks, events on eight lanes, 0/0/0); + `./target/release/audit capi` `pcm_digest` `bffc21e857b5e134` (unchanged), 0 violations, 0 + allocations. +- R17-4: `the_scalar_oracle_renders_the_banks_fader_events` (Scalar against Simd4: no live control, + live controls, between render calls; each with a live mute and unmute ramp on an automated lane; + red with the oracle's events disabled, checked once). +- Commands: `cargo test --locked -p automation -p graph -p builtins-compiler --features + builtins-compiler/test-support,graph/test-support`; `-p host-core --features + host-core/test-support` (all); `-p capi`; `-p host-web --features host-web/test-support` + (including `control_parity`): pass. `realtime-policy`: ok, 159 regions in 33 files (155 + the + lane-automation impl, the driver, the bank helpers and the oracle render). fmt; clippy `-D + warnings` (touched crates, all targets, all features); no-feature `cargo check --all-targets`. +- Bytes: `lane_slot_bytes` 168 -> 192 (x86-64: `fresh` and the two counters), measured by + `fader_automation_sizes()` and charged by #1646's sum; no fixture moved. + +**`expect(dead_code)` left.** `OffsetsLane` (the cell and its dirty word) and +`AutomatedFaderCell::{offsets, offsets_ramp}`: #1649 writes the cell, adds its render read and +uses the ramp word. Removed: `LaneFaderAutomation`, `AutomatedFaderCell` (cell, state, `S`), the +oracle `FaderProcessor` field and `LiveControlFaderProcessor::automation`. + +**Notes for #1647c.** The move at `E` needs a lane's events in two pieces (`[.., E]` and +`[E + 1, ..]`): a cursor holds one `CellEvents`, which borrows the `CellState`, so the second piece +needs the first iterator dropped; give `FaderCellCursor` a pending second span and rebuild its +`CellEvents` when the first ends (or return the state from `CellEvents`). D1.2's report is +`report_held_ramp` at the drain; the adoption re-report belongs with #1648's carry, which must also +clear `fresh`. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 0b3644f12..129ee7b56 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -623,7 +623,8 @@ const OFFSETS_CELL_WORDS: usize = 3; /// write (#1646 departure 5, as #1639's jump-length cell does). #[expect( dead_code, - reason = "#1649 writes the cell and #1647 reads it in render, in the same push (batch R1)" + reason = "#1649 writes the cell and adds its render read (until then render composes with \ + the held `S`, #1647b), in the same push (batch R1)" )] struct OffsetsLane { cell: LatestCell, @@ -636,31 +637,31 @@ struct OffsetsLane { /// /// The event state lives here, beside the lane's ramp state, so the stage's carry moves it with /// the lane (#1648 extends [`FaderBankProcessor::adopt_lane`]). -#[expect( - dead_code, - reason = "#1647 drives the events and composes the gain from these in render, in the same push \ - (batch R1)" -)] struct AutomatedFaderCell { /// The cell's index in the plan's [`automation::Program`]. cell: u32, /// Seeded with the prepared curve value, so the first grid event on a flat curve emits nothing. state: automation::CellState, + #[expect( + dead_code, + reason = "#1649 writes the cell and adds its render read, in the same push (batch R1)" + )] offsets: Arc, - /// `S`, held until the offsets cell's first applied write. + /// `S`, held until the offsets cell's first applied write; every event's gain is composed + /// with it (#1646 D1, #1647 D1.4). offsets_sum: f64, /// The ramp word, held until the offsets cell's first applied write: the session's fader /// length (`LiveRampRow::VcaOffset`, #1054). + #[expect( + dead_code, + reason = "#1649 reads it as the length of a VCA edit's jump, in the same push (batch R1)" + )] offsets_ramp: u32, } /// One strip lane's stored fader automation (#1646 D1): the plan's program and the lane's /// automated channels. The one event driver both fader owners hold, the fader bank per lane and /// the test-only scalar oracle per strip (R17-4), so neither has a second copy of the rule. -#[expect( - dead_code, - reason = "#1647 drives the events from the program in render, in the same push (batch R1)" -)] struct LaneFaderAutomation { program: Arc, /// `[left, right]`; `None` for a channel no entry drives. @@ -669,12 +670,24 @@ struct LaneFaderAutomation { /// bank lane at the end of `compile_with_builtins`; `0` until then. arrival: u64, /// The node-time mapping of the block the graph last set (#1647a, R17-3): - /// `timeline_block(r + ΣP - a(n))`, `None` in a plan with no timeline. #1647b builds the - /// lane's `NodeSpan` from it. + /// `timeline_block(r + ΣP - a(n))`, `None` in a plan with no timeline. The event driver builds + /// the lane's `NodeSpan` from it. span: Option, /// The fader row's jump length of that block, from the three lengths the graph holds (#1639 - /// D2); `None` in a plan with no jump-length cell. #1647b passes it to `CellEvents::new`. + /// D2); `None` in a plan with no jump-length cell. The event driver passes it to + /// `CellEvents::new`. fader_jump: Option, + /// The lane has rendered no block yet (#1647 D2): its first block takes a `Set` when the + /// curve at that block's node time differs from the value it was seeded with. + fresh: bool, + /// Events render refused (#1647 D1, unreachable): a gain the conversion refuses, a sink + /// refusal, a cell the program does not hold, a span the cell refuses or a cell past the + /// driver's capacity. Each also fails a `debug_assert`. Read off render. + refused_events: u64, + /// Blocks in which the stage stopped pulling this lane's owner's operations + /// (`BuiltinProcessReport::fader_operations_stopped`, #1638): a bank's stop is counted on each + /// of its automated lanes. Read off render. + operations_stopped: u64, } impl LaneFaderAutomation { @@ -702,10 +715,375 @@ impl LaneFaderAutomation { arrival: 0, span: None, fader_jump: None, + fresh: true, + refused_events: 0, + operations_stopped: 0, + } + } + + // REALTIME_POLICY_BEGIN + /// Take the block's node-time mapping and the fader row's jump length (#1647a, R17-3): the + /// one path of both fader owners, the bank per lane and the scalar oracle per strip. + fn take_block_time(&mut self, time: &GraphBlockTime<'_>, fader_jump: Option) { + self.span = time.node_span(self.arrival); + self.fader_jump = fader_jump; + } + + /// D1.2: report to `channel`'s cell a held ramp the live drain started at this block's first + /// sample (a live mute or unmute ramp) of `remaining` samples, so its grid holds inside it. + /// The ramp completes at the node time of its last sample (`Completion::End`); with no span + /// or a node time past the `i64` range nothing is reported. + fn report_held_ramp(&mut self, channel: usize, remaining: u32) { + let Some(last) = remaining.checked_sub(1) else { + return; + }; + let Some(span) = self.span else { + return; + }; + if let (Some(Some(cell)), Some(end)) = ( + self.channels.get_mut(channel), + node_time_at(span, u64::from(last)), + ) { + cell.state.hold_until(end); + } + } + // REALTIME_POLICY_END +} + +// REALTIME_POLICY_BEGIN +/// The node time of sample `offset` of a node block mapped by `span` (`offset` may lie past the +/// block: the mapping extends forward), or `None` past the `i64` range. Checked: a span saturated +/// at the `i64` edges (R17-5) never wraps here. +fn node_time_at(span: TimelineSpan, offset: u64) -> Option { + let (base, from) = match span.step { + Some(step) if offset >= u64::from(step.offset) => (step.timeline, u64::from(step.offset)), + _ => (span.start, 0), + }; + base.checked_add(i64::try_from(offset - from).ok()?) +} + +/// The fader row's jump length of a block (#1639 D2, #1647a departure 7), resolved once from the +/// three lengths the graph holds through `LiveRamps`, which owns the row rule; `None` in a plan +/// with no jump-length cell. +fn block_fader_jump(time: &GraphBlockTime<'_>) -> Option { + time.jump_lengths().map(|lengths| { + controls::LiveRamps::from_lengths( + lengths.fader_samples, + lengths.mute_samples, + lengths.pan_samples, + ) + .for_row(effect_contract::control::LiveRampRow::Fader) + }) +} + +/// The channels a live record's selector covers, as `[left, right]`. +const fn selector_channels(lanes: BuiltinLaneSelector) -> [bool; 2] { + match lanes { + BuiltinLaneSelector::Left => [true, false], + BuiltinLaneSelector::Right => [false, true], + BuiltinLaneSelector::Both => [true, true], + } +} + +/// The most cells one fader owner drives in a block: two channels on each of a bank's lanes. +const MAXIMUM_FADER_CELLS: usize = 2 * MAXIMUM_BANK_LANES; + +/// One automated channel's events of one block, with the next one pulled (#1647 D1.3). +struct FaderCellCursor<'a> { + /// The owner's lane (the bank lane; `0` on the scalar oracle). + lane: usize, + /// `0` left, `1` right. + channel: usize, + /// `S`, the offset sum every event's value is composed with (#1646 D1). + offsets_sum: f64, + events: automation::CellEvents<'a, 'a>, + next: Option, +} + +/// The one event driver of stored fader automation (#1647 D1.3-D1.5, R17-4): both fader owners, +/// the fader bank and the test-only scalar oracle, render a block's events through it. +/// +/// It pulls each automated channel's events (#1637, [`automation::SeekMode::Exact`]) and merges +/// them by offset with one fixed cursor per channel, with no buffer: the stage asks for the +/// lowest pending offset and takes every channel's event at it ([`builtins::FaderOperationSource`]). +/// Each event's value is composed with the channel's `S` and converted to a fader gain as the +/// static path does (`checked_fader_gain(vca_compose_db(value, S))`, #1646 D1); a grid or jump +/// event becomes a `RetargetGain` over its ramp, a held event a `RememberGain` and a seek or +/// restart landing a `SetGain` (#1638 D1). Fixed size, on the render stack; allocation-, lock- and +/// syscall-free. +struct FaderEventDriver<'a> { + cursors: [Option>; MAXIMUM_FADER_CELLS], + /// Refused events per owner lane, returned by [`Self::finish`]. + refused: [u64; MAXIMUM_BANK_LANES], + #[cfg(any(test, feature = "test-support"))] + first_sample: u64, +} + +impl<'a> FaderEventDriver<'a> { + /// The block of `frames` samples of every automated lane in `lanes`, `(owner lane, lane's + /// automation)`. A lane with no node time this block (no timeline, no jump length, or a span + /// saturated at the `i64` edge) holds its value and emits nothing. + /// + /// D2, departure 4: a lane's first block, with no seek reaching it, lands a `Set` at offset + /// 0 when the curve at the block's node time differs from the value the lane was seeded + /// with, so a fresh plan (seeded with the curve at `-a(n)`) emits none and a successor's + /// restarted lane is set to its curve. A seek step in that first block lands instead: the + /// samples before it precede the seek on the node. + fn new( + lanes: impl Iterator, + frames: u32, + first_sample: u64, + ) -> Self { + let _ = first_sample; + let mut driver = Self { + cursors: core::array::from_fn(|_| None), + refused: [0; MAXIMUM_BANK_LANES], + #[cfg(any(test, feature = "test-support"))] + first_sample, + }; + if frames == 0 { + return driver; + } + let mut used = 0_usize; + for (lane, automation) in lanes { + let LaneFaderAutomation { + program, + channels, + span, + fader_jump, + fresh, + .. + } = automation; + let first_block = core::mem::replace(fresh, false); + let (Some(span), Some(jump)) = (*span, *fader_jump) else { + continue; + }; + let length = i64::from(frames); + if span.start.checked_add(length).is_none() + || span + .step + .is_some_and(|step| step.timeline.checked_add(length).is_none()) + { + continue; + } + let program: &'a automation::Program = program; + for (channel, cell) in channels.iter_mut().enumerate() { + let Some(cell) = cell.as_mut() else { + continue; + }; + let refused = |driver: &mut Self| { + debug_assert!(false, "an automated fader cell is refused at render"); + if let Some(count) = driver.refused.get_mut(lane) { + *count = count.saturating_add(1); + } + }; + let Some(cell_program) = program.cell(cell.cell as usize) else { + refused(&mut driver); + continue; + }; + let step = span + .step + .map(|step| (step.offset, step.timeline)) + .or_else(|| { + let value = automation::value_at(cell_program.segments(), span.start); + (first_block + && cell.state.target().map(f32::to_bits) != Some(value.to_bits())) + .then_some((0, span.start)) + }); + let (Ok(node), Some(slot)) = ( + automation::NodeSpan::new(span.start, step, frames), + driver.cursors.get_mut(used), + ) else { + refused(&mut driver); + continue; + }; + let mut events = automation::CellEvents::new( + cell_program, + &mut cell.state, + node, + controls::FADER_CELL_RULE.seek, + jump, + ); + let next = events.next(); + *slot = Some(FaderCellCursor { + lane, + channel, + offsets_sum: cell.offsets_sum, + events, + next, + }); + used += 1; + } + } + driver + } + + /// The fader operation of `event` on a channel whose offset sum is `offsets_sum`, or `None` + /// when the composed value has no fader gain (unreachable: #1645 bounds the curve to the + /// fader domain and `vca_compose_db` clamps, #1646 D6). + fn operation(event: automation::Event, offsets_sum: f64) -> Option { + let db = session::vca_compose_db(event.value, offsets_sum); + let gain = builtins::checked_fader_gain(db).ok()?; + Some(if event.held { + builtins::FaderOperation::RememberGain { gain } + } else { + match event.kind { + automation::EventKind::Set => builtins::FaderOperation::SetGain { gain }, + automation::EventKind::Grid | automation::EventKind::Jump => { + builtins::FaderOperation::RetargetGain { + gain, + samples: event.ramp, + } + } + } + }) + } + + /// Consume every event the stage did not pull (none, unless a stage stopped pulling: the + /// cell's state must still advance through its block) and return the refused events per + /// owner lane. + fn finish(mut self) -> [u64; MAXIMUM_BANK_LANES] { + for cursor in self.cursors.iter_mut().flatten() { + while cursor.next.is_some() { + cursor.next = cursor.events.next(); + } } + self.refused } } +impl builtins::FaderOperationSource for FaderEventDriver<'_> { + fn next_offset(&mut self) -> Option { + self.cursors + .iter() + .flatten() + .filter_map(|cursor| cursor.next.map(|event| event.offset)) + .min() + } + + fn apply(&mut self, offset: u32, sink: &mut builtins::FaderOperationSink) { + #[cfg(any(test, feature = "test-support"))] + let first_sample = self.first_sample; + let Self { + cursors, refused, .. + } = self; + for cursor in cursors.iter_mut().flatten() { + let Some(event) = cursor.next.filter(|event| event.offset == offset) else { + continue; + }; + let selector = if cursor.channel == 0 { + BuiltinLaneSelector::Left + } else { + BuiltinLaneSelector::Right + }; + let pushed = Self::operation(event, cursor.offsets_sum) + .is_some_and(|operation| sink.push(cursor.lane, selector, operation).is_ok()); + if !pushed { + debug_assert!(false, "a stored fader event is refused at render"); + if let Some(count) = refused.get_mut(cursor.lane) { + *count = count.saturating_add(1); + } + } + #[cfg(any(test, feature = "test-support"))] + test_only_record_fader_event(TestOnlyFaderEvent { + lane: cursor.lane, + channel: cursor.channel, + sample: first_sample.saturating_add(u64::from(offset)), + kind: event.kind, + held: event.held, + value: event.value, + ramp: event.ramp, + }); + cursor.next = cursor.events.next(); + } + } +} +// REALTIME_POLICY_END + +/// Test-only: one stored fader event the driver gave the stage (#1647b, gate 3's event probe). +#[cfg(any(test, feature = "test-support"))] +#[derive(Clone, Copy, Debug, PartialEq)] +#[doc(hidden)] +pub struct TestOnlyFaderEvent { + /// The owner lane: the bank lane, `0` on the scalar oracle. + pub lane: usize, + /// `0` left, `1` right. + pub channel: usize, + /// The render sample the event applies at: the block's first sample plus its offset. + pub sample: u64, + /// What produced it. + pub kind: automation::EventKind, + /// A held grid event (a `RememberGain`). + pub held: bool, + /// The curve value, before `S`. + pub value: f32, + /// The ramp length. + pub ramp: u32, +} + +#[cfg(any(test, feature = "test-support"))] +const FADER_EVENT_CAPACITY: usize = 64; + +#[cfg(any(test, feature = "test-support"))] +#[derive(Clone, Copy)] +struct TestOnlyFaderEvents { + len: usize, + overflow: bool, + entries: [TestOnlyFaderEvent; FADER_EVENT_CAPACITY], +} + +#[cfg(any(test, feature = "test-support"))] +thread_local! { + static FADER_EVENTS: core::cell::Cell = const { + core::cell::Cell::new(TestOnlyFaderEvents { + len: 0, + overflow: false, + entries: [TestOnlyFaderEvent { + lane: 0, + channel: 0, + sample: 0, + kind: automation::EventKind::Grid, + held: false, + value: 0.0, + ramp: 0, + }; FADER_EVENT_CAPACITY], + }) + }; +} + +/// Append one event to this thread's probe. Allocation-free: a copy in and out of a fixed array. +#[cfg(any(test, feature = "test-support"))] +fn test_only_record_fader_event(event: TestOnlyFaderEvent) { + FADER_EVENTS.with(|cell| { + let mut events = cell.get(); + match events.entries.get_mut(events.len) { + Some(free) => { + *free = event; + events.len += 1; + } + None => events.overflow = true, + } + cell.set(events); + }); +} + +/// Test-only (#1647b): take the stored fader events this thread's fader owners gave their stages +/// since the last take, in order, and whether the probe's capacity overflowed. +#[cfg(any(test, feature = "test-support"))] +#[doc(hidden)] +#[must_use] +pub fn test_only_take_fader_events() -> (Vec, bool) { + FADER_EVENTS.with(|cell| { + let events = cell.get(); + cell.set(TestOnlyFaderEvents { + len: 0, + overflow: false, + ..events + }); + (events.entries[..events.len].to_vec(), events.overflow) + }) +} + /// One automated fader channel as preparation computed it (#1646 D1, D2): plain data, no /// allocation. #[derive(Clone, Copy, Debug)] @@ -1906,9 +2284,22 @@ pub fn test_only_take_fader_lane_times() -> (Vec, bool) { impl FaderBankProcessor { /// The block drain under `snapshot`: the body of `begin_block`. + /// + /// D1.2: a held ramp the drain starts on an automated lane is reported to its cell once, here, + /// at the block entry that starts it (#1647, batch 13b amendment, constraint 1). fn drain(&mut self, snapshot: LiveSnapshot) -> Result<(), RenderError> { - let Self { bank, controls, .. } = self; - drain_fader_controls(bank, controls, snapshot, EVERY_LANE) + let Self { + bank, + controls, + automation, + .. + } = self; + let mut held = [0_u8; 2]; + let result = drain_fader_controls_marking(bank, controls, snapshot, EVERY_LANE, &mut held); + if held != [0; 2] { + report_held_ramps(bank, automation, held); + } + result } /// Apply every pending value of the cells of the lanes in `lanes`, whatever its revision: the @@ -1984,17 +2375,9 @@ impl GraphPreparedBuiltinBankProcessor for FaderBankProcessor { if self.automation.is_empty() { return; } - let fader_jump = time.jump_lengths().map(|lengths| { - controls::LiveRamps::from_lengths( - lengths.fader_samples, - lengths.mute_samples, - lengths.pan_samples, - ) - .for_row(effect_contract::control::LiveRampRow::Fader) - }); + let fader_jump = block_fader_jump(time); for slot in self.automation.iter_mut().flatten() { - slot.span = time.node_span(slot.arrival); - slot.fader_jump = fader_jump; + slot.take_block_time(time, fader_jump); } #[cfg(any(test, feature = "test-support"))] test_only_record_fader_lane_times(&self.automation); @@ -2062,9 +2445,19 @@ impl GraphPreparedBuiltinBankProcessor for FaderBankProcessor { frames: u32, first_sample: u64, ) -> Result<(), RenderError> { - let _ = first_sample; - let Self { bank, .. } = self; - bank.process(left, right, frames); + let Self { + bank, automation, .. + } = self; + if automation.is_empty() { + bank.process(left, right, frames); + } else { + // #1647 D1.3-D1.5: the automated lanes' events, through the stage's timed path. + let mut driver = + FaderEventDriver::new(automated_lanes(automation), frames, first_sample); + let report = bank.process_with(left, right, frames, &mut driver); + let refused = driver.finish(); + settle_fader_events(automation, refused, report); + } self.process_calls = self.process_calls.saturating_add(1); self.frames_processed = self.frames_processed.saturating_add(u64::from(frames)); Ok(()) @@ -2090,6 +2483,53 @@ impl GraphPreparedBuiltinBankProcessor for FaderBankProcessor { } } +// REALTIME_POLICY_BEGIN +/// A fader bank's automated lanes, `(bank lane, automation)`, for [`FaderEventDriver::new`]. +fn automated_lanes( + automation: &mut [Option], +) -> impl Iterator { + automation + .iter_mut() + .enumerate() + .filter_map(|(lane, slot)| slot.as_mut().map(|slot| (lane, slot))) +} + +/// Add a block's refused events and the stage's stop to the automated lanes' counters. +fn settle_fader_events( + automation: &mut [Option], + refused: [u64; MAXIMUM_BANK_LANES], + report: builtins::BuiltinProcessReport, +) { + for (slot, refused) in automation.iter_mut().zip(refused) { + if let Some(slot) = slot { + slot.refused_events = slot.refused_events.saturating_add(refused); + slot.operations_stopped = slot + .operations_stopped + .saturating_add(report.fader_operations_stopped); + } + } +} + +/// D1.2: report each held ramp the block drain started on an automated lane (bit `lane` of +/// `held[channel]`) to that lane's cell, from the stage's remaining count. +fn report_held_ramps( + bank: &BuiltinFaderBank, + automation: &mut [Option], + held: [u8; 2], +) { + for (lane, slot) in automated_lanes(automation) { + for (channel, marks) in held.iter().enumerate() { + if lane_selected(*marks, lane) + && bank.gain_held(lane, channel) == Some(true) + && let Some(remaining) = bank.ramp_remaining(lane, channel) + { + slot.report_held_ramp(channel, remaining); + } + } + } +} +// REALTIME_POLICY_END + /// One strip matrix/pan bank and the live-control channels of its member lanes (issue #212). /// /// The mirror of [`FaderBankProcessor`], on `TrackControlRecord`, and it carries the same drain @@ -2545,6 +2985,18 @@ fn drain_fader_controls( controls: &mut [Option], snapshot: LiveSnapshot, lanes: u8, +) -> Result<(), RenderError> { + drain_fader_controls_marking(bank, controls, snapshot, lanes, &mut [0; 2]) +} + +/// [`drain_fader_controls`], also setting bit `lane` of `held[channel]` for each lane and channel +/// a drained mute record with a nonzero window started a held ramp on (#1647 D1.2). +fn drain_fader_controls_marking( + bank: &mut BuiltinFaderBank, + controls: &mut [Option], + snapshot: LiveSnapshot, + lanes: u8, + held: &mut [u8; 2], ) -> Result<(), RenderError> { let mut result = Ok(()); for (lane, control) in controls.iter_mut().enumerate() { @@ -2565,7 +3017,17 @@ fn drain_fader_controls( FaderKind::Fader => { bank.set_fader_db(lane, lanes, f32::from_bits(words[0]), words[1]) } - FaderKind::Mute => bank.set_mute(lane, lanes, words[0] != 0, words[1]), + FaderKind::Mute => { + if words[1] != 0 { + let bit = 1_u8.checked_shl(lane as u32).unwrap_or(0); + for (marks, covered) in held.iter_mut().zip(selector_channels(lanes)) { + if covered { + *marks |= bit; + } + } + } + bank.set_mute(lane, lanes, words[0] != 0, words[1]) + } } }); result = result.and(drained.map_err(render_error)); @@ -2767,10 +3229,25 @@ impl GraphPreparedBuiltinBankProcessor for FaderMatrixBankProcessor { value.set(counters); }); let Self { fader, matrix } = self; - if !fader - .bank - .try_process_settled_with_matrix(&mut matrix.bank, left, right, frames) - { + let FaderBankProcessor { + bank, automation, .. + } = &mut **fader; + // #1647 D1.5: with stored automation the pair fuses only when no fader lane has an event + // in the block (the matrix half declines a ramping matrix itself); its fallback renders + // the events through the stage's timed path. + let mut driver = (!automation.is_empty()) + .then(|| FaderEventDriver::new(automated_lanes(automation), frames, first_sample)); + let fused = match driver.as_mut() { + None => bank.try_process_settled_with_matrix(&mut matrix.bank, left, right, frames), + Some(driver) => bank.try_process_settled_with_matrix_and_operations( + &mut matrix.bank, + left, + right, + frames, + driver, + ), + }; + if !fused { #[cfg(any(test, feature = "test-support"))] FADER_MATRIX_FALLBACK_CALLS.fetch_add(1, core::sync::atomic::Ordering::Relaxed); #[cfg(any(test, feature = "test-support"))] @@ -2779,8 +3256,18 @@ impl GraphPreparedBuiltinBankProcessor for FaderMatrixBankProcessor { counters[2] = counters[2].saturating_add(1); value.set(counters); }); - fader.bank.process(left, right, frames); + let report = match driver.as_mut() { + None => { + bank.process(left, right, frames); + builtins::BuiltinProcessReport::default() + } + Some(driver) => bank.process_with(left, right, frames, driver), + }; matrix.bank.process(left, right, frames); + if let Some(driver) = driver.take() { + let refused = driver.finish(); + settle_fader_events(automation, refused, report); + } } else { #[cfg(any(test, feature = "test-support"))] FADER_MATRIX_FUSED_CALLS.fetch_add(1, core::sync::atomic::Ordering::Relaxed); @@ -2790,6 +3277,14 @@ impl GraphPreparedBuiltinBankProcessor for FaderMatrixBankProcessor { counters[1] = counters[1].saturating_add(1); value.set(counters); }); + if let Some(driver) = driver.take() { + let refused = driver.finish(); + settle_fader_events( + automation, + refused, + builtins::BuiltinProcessReport::default(), + ); + } } fader.process_calls = fader.process_calls.saturating_add(1); fader.frames_processed = fader.frames_processed.saturating_add(u64::from(frames)); @@ -2802,7 +3297,6 @@ impl GraphPreparedBuiltinBankProcessor for FaderMatrixBankProcessor { right.first().copied().unwrap_or(0.0).to_bits(), ]); }); - let _ = first_sample; Ok(()) } fn qualification_counters(&self) -> [u64; 2] { @@ -3937,7 +4431,16 @@ impl PreparedBuiltinsSession { ) = match control { None => ( Box::new(InputProcessor(input)), - Box::new(FaderProcessor(fader, automation)), + match automation { + // #1647b (R17-4): an automated strip binds the ramped fader, which takes + // the events' timed operations. + Some(automation) => Box::new(AutomatedFaderProcessor { + fader: FaderMuteRampBuiltins::new(parameters) + .expect("preparation validated the ramped fader's gain domain"), + automation, + }), + None => Box::new(FaderProcessor(fader)), + }, Box::new(MatrixProcessor(matrix)), ), Some(control) => { @@ -4668,6 +5171,12 @@ impl PreparedBuiltinsGraphArtifact { arrivals: &[(GraphNodeId, effect_contract::LatencySamples)], ) { self.graph.install_builtin_input_arrivals(arrivals); + // #1647b (R17-4): the scalar oracle's bound fader owners take their node's `a(n)` by the + // same lookup. + for binding in &mut self.builtin_processors { + let arrival = graph::floored_input_arrival(arrivals, &binding.node); + binding.set_input_arrival(arrival); + } } /// Immutable caller-owned graph report. #[must_use] @@ -5981,16 +6490,7 @@ impl GraphRuntimeProcessor for InputProcessor { } } #[cfg(any(test, feature = "test-support"))] -struct FaderProcessor( - FaderMuteBuiltins, - /// The strip's stored fader automation (#1646 D1, R17-4), boxed so a strip without it keeps - /// one word. - #[expect( - dead_code, - reason = "#1647 drives it in the oracle's render, in the same push (batch R1)" - )] - Option>, -); +struct FaderProcessor(FaderMuteBuiltins); #[cfg(any(test, feature = "test-support"))] impl GraphRuntimeProcessor for FaderProcessor { fn process(&mut self, block: GraphBindingBlock<'_>) -> Result<(), RenderError> { @@ -6003,6 +6503,75 @@ impl GraphRuntimeProcessor for FaderProcessor { SEAM_SIDE_WITNESS } } + +/// The scalar oracle's fader of a strip with stored fader automation and no live control +/// (#1647b, R17-4, #1646b departure 5): the ramped fader, which takes timed operations, driven by +/// the one event driver the fader bank uses. [`FaderProcessor`] wraps the unramped fader, which +/// takes none, so an automated strip binds this instead. +#[cfg(any(test, feature = "test-support"))] +struct AutomatedFaderProcessor { + fader: FaderMuteRampBuiltins, + /// The strip's stored fader automation (#1646 D1), boxed as on the live-control owner. + automation: Box, +} +#[cfg(any(test, feature = "test-support"))] +impl GraphRuntimeProcessor for AutomatedFaderProcessor { + fn process(&mut self, block: GraphBindingBlock<'_>) -> Result<(), RenderError> { + render_oracle_fader( + &mut self.fader, + Some(&mut self.automation), + block.left, + block.right, + block.first_sample, + ) + } + fn set_input_arrival(&mut self, arrival: u64) { + self.automation.arrival = arrival; + } + fn set_block_time(&mut self, time: &GraphBlockTime<'_>) { + self.automation + .take_block_time(time, block_fader_jump(time)); + } + fn channel_symmetry(&self) -> ChannelSymmetryWitness { + SEAM_SIDE_WITNESS + } +} + +// REALTIME_POLICY_BEGIN +/// The scalar oracle's fader render (#1647b, R17-4): with stored automation, the block's events +/// through the one event driver into the ramped fader's timed path, exactly as a fader bank lane +/// renders them; without, the plain block. +#[cfg(any(test, feature = "test-support"))] +fn render_oracle_fader( + fader: &mut FaderMuteRampBuiltins, + automation: Option<&mut LaneFaderAutomation>, + left: &mut [f32], + right: &mut [f32], + first_sample: u64, +) -> Result<(), RenderError> { + let frames = u32::try_from(left.len()).unwrap_or(u32::MAX); + let block = DualMonoBlock::new(left, right, first_sample).map_err(render_error)?; + match automation { + None => { + fader.process(block); + } + Some(automation) => { + let mut driver = FaderEventDriver::new( + core::iter::once((0, &mut *automation)), + frames, + first_sample, + ); + let report = fader.process_with(block, &mut driver); + let [refused, ..] = driver.finish(); + automation.refused_events = automation.refused_events.saturating_add(refused); + automation.operations_stopped = automation + .operations_stopped + .saturating_add(report.fader_operations_stopped); + } + } + Ok(()) +} +// REALTIME_POLICY_END #[cfg(any(test, feature = "test-support"))] struct MatrixProcessor(MatrixBuiltins); #[cfg(any(test, feature = "test-support"))] @@ -6178,10 +6747,6 @@ struct LiveControlFaderProcessor { control: FaderReader, control_delivery: BuiltinControlDelivery, /// The strip's stored fader automation (#1646 D1, R17-4): the bank's event driver, per strip. - #[expect( - dead_code, - reason = "#1647 drives it in the oracle's render, in the same push (batch R1)" - )] automation: Option>, #[cfg(any(test, feature = "test-support"))] test_only_owner: u16, @@ -6203,13 +6768,27 @@ impl GraphRuntimeProcessor for LiveControlFaderProcessor { #[cfg(any(test, feature = "test-support"))] record_scalar_fader_state(self.test_only_owner, &self.fader); })?; - let block = DualMonoBlock::new(block.left, block.right, block.first_sample) - .map_err(render_error)?; - self.fader.process(block); + render_oracle_fader( + &mut self.fader, + self.automation.as_deref_mut(), + block.left, + block.right, + block.first_sample, + )?; #[cfg(any(test, feature = "test-support"))] record_scalar_fader_state(self.test_only_owner, &self.fader); Ok(()) } + fn set_input_arrival(&mut self, arrival: u64) { + if let Some(automation) = self.automation.as_deref_mut() { + automation.arrival = arrival; + } + } + fn set_block_time(&mut self, time: &GraphBlockTime<'_>) { + if let Some(automation) = self.automation.as_deref_mut() { + automation.take_block_time(time, block_fader_jump(time)); + } + } fn scalar_pair_factory(&self) -> Option { (self.control_delivery == BuiltinControlDelivery::BetweenRenderCalls) .then_some(make_scalar_pair) @@ -6223,9 +6802,14 @@ impl GraphRuntimeProcessor for LiveControlFaderProcessor { #[cfg(any(test, feature = "test-support"))] impl LiveControlFaderProcessor { /// The fader and mute cells' drain under the block's `snapshot` (#1312 D12). + /// + /// D1.2: a held ramp the drain starts is reported to the automated channel's cell, as the + /// fader bank's drain does. fn drain_controls(&mut self, snapshot: LiveSnapshot) -> Result<(), RenderError> { let fader = &mut self.fader; - self.control + let mut held = [false; 2]; + let result = self + .control .apply_latest(snapshot, |kind, lanes, words| { #[cfg(any(test, feature = "test-support"))] FADER_MATRIX_LIVE_WITNESS.with(|value| { @@ -6238,12 +6822,27 @@ impl LiveControlFaderProcessor { fader.set_fader_db(lanes, f32::from_bits(words[0]), words[1]) } FaderKind::Mute => { + if words[1] != 0 { + for (mark, covered) in held.iter_mut().zip(selector_channels(lanes)) { + *mark |= covered; + } + } fader.set_mute(lanes, words[0] != 0, words[1]); Ok(()) } } }) - .map_err(render_error) + .map_err(render_error); + if let Some(automation) = self.automation.as_deref_mut() { + for (channel, _) in held.iter().enumerate().filter(|(_, held)| **held) { + if self.fader.gain_held(channel) == Some(true) + && let Some(remaining) = self.fader.ramp_remaining(channel) + { + automation.report_held_ramp(channel, remaining); + } + } + } + result } /// Apply every pending value, whatever its revision (#1312 D6): the block drain with @@ -6280,6 +6879,12 @@ impl Drop for ScalarPairProcessor { #[cfg(any(test, feature = "test-support"))] impl GraphRuntimeProcessor for ScalarPairProcessor { + fn set_input_arrival(&mut self, arrival: u64) { + self.fader.set_input_arrival(arrival); + } + fn set_block_time(&mut self, time: &GraphBlockTime<'_>) { + self.fader.set_block_time(time); + } fn process(&mut self, block: GraphBindingBlock<'_>) -> Result<(), RenderError> { #[cfg(any(test, feature = "test-support"))] FADER_MATRIX_LIVE_WITNESS.with(|value| { @@ -6304,6 +6909,7 @@ impl GraphRuntimeProcessor for ScalarPairProcessor { })?; // Preserve the original fader boundary: its queue is drained before the envelope is // checked, and an invalid envelope stops before the later matrix owner consumes anything. + let automated = self.fader.automation.is_some(); let fused = { let mut fused_block = DualMonoBlock::new(&mut *left, &mut *right, first_sample).map_err(render_error)?; @@ -6311,7 +6917,17 @@ impl GraphRuntimeProcessor for ScalarPairProcessor { if let Some(error) = matrix_error { // Preserve the original failure order: the fader arithmetic completes even when // a later matrix command is invalid. - self.fader.fader.process(fused_block); + if automated { + render_oracle_fader( + &mut self.fader.fader, + self.fader.automation.as_deref_mut(), + &mut *left, + &mut *right, + first_sample, + )?; + } else { + self.fader.fader.process(fused_block); + } #[cfg(any(test, feature = "test-support"))] record_scalar_state( self.fader.test_only_owner, @@ -6320,9 +6936,12 @@ impl GraphRuntimeProcessor for ScalarPairProcessor { ); return Err(error); } - self.fader - .fader - .process_fader_matrix(&mut self.matrix.matrix, &mut fused_block) + // #1647 D1.5: an automated fader renders its events on the two-stage path. + !automated + && self + .fader + .fader + .process_fader_matrix(&mut self.matrix.matrix, &mut fused_block) }; if fused { #[cfg(any(test, feature = "test-support"))] @@ -6352,9 +6971,13 @@ impl GraphRuntimeProcessor for ScalarPairProcessor { counters[2] = counters[2].saturating_add(1); value.set(counters); }); - self.fader.fader.process( - DualMonoBlock::new(&mut *left, &mut *right, first_sample).map_err(render_error)?, - ); + render_oracle_fader( + &mut self.fader.fader, + self.fader.automation.as_deref_mut(), + &mut *left, + &mut *right, + first_sample, + )?; self.matrix.matrix.process( DualMonoBlock::new(&mut *left, &mut *right, first_sample).map_err(render_error)?, ); @@ -6409,6 +7032,9 @@ impl Drop for ScalarSplitPairProcessor { #[cfg(any(test, feature = "test-support"))] impl GraphRuntimeSplitPairProcessor for ScalarSplitPairProcessor { + fn set_block_time(&mut self, time: &GraphBlockTime<'_>) { + self.fader.set_block_time(time); + } fn begin_fader(&mut self, block: GraphBindingBlock<'_>) -> Result<(), RenderError> { #[cfg(any(test, feature = "test-support"))] FADER_MATRIX_PROCESS_CALLS.fetch_add(1, Ordering::Relaxed); @@ -6438,18 +7064,29 @@ impl GraphRuntimeSplitPairProcessor for ScalarSplitPairProcessor { // Validate the envelope before recording deferred state. A malformed fader block must // leave the later matrix consumer untouched and cannot leave a completion to run after // the graph has already returned the original error. - let block = DualMonoBlock::new(left, right, first_sample).map_err(render_error)?; - if self.fader.fader.is_settled() { - self.pending_fader = true; - #[cfg(any(test, feature = "test-support"))] - record_scalar_state( - self.fader.test_only_owner, - &self.fader.fader, - &self.matrix.matrix, - ); - return Ok(()); + if self.fader.automation.is_some() { + // #1647 D1.5: an automated fader never defers; its events render now. + render_oracle_fader( + &mut self.fader.fader, + self.fader.automation.as_deref_mut(), + left, + right, + first_sample, + )?; + } else { + let block = DualMonoBlock::new(left, right, first_sample).map_err(render_error)?; + if self.fader.fader.is_settled() { + self.pending_fader = true; + #[cfg(any(test, feature = "test-support"))] + record_scalar_state( + self.fader.test_only_owner, + &self.fader.fader, + &self.matrix.matrix, + ); + return Ok(()); + } + self.fader.fader.process(block); } - self.fader.fader.process(block); self.pending_fader = false; #[cfg(any(test, feature = "test-support"))] record_scalar_state( diff --git a/crates/capi/src/runtime/tests.rs b/crates/capi/src/runtime/tests.rs index 7d2c59ee3..5dd1a70f5 100644 --- a/crates/capi/src/runtime/tests.rs +++ b/crates/capi/src/runtime/tests.rs @@ -6492,3 +6492,141 @@ fn a_flat_fader_automation_renders_the_static_fader_bits_on_the_c_abi() { } } } + +/// #1647 gate 4 (C ABI): the timeline sample of the seek and the fader ride that is flat around +/// it: `0` dB before timeline 20,000 and [`SEEK_RIDE_DB`] from it on. +const SEEK_TIMELINE: u64 = 40_000; +const SEEK_RIDE_DB: f32 = -9.0; + +/// `model` with track 1's fader on the ride (automated) or held at [`SEEK_RIDE_DB`] (reference). +fn seek_ride(mut model: session::SessionModel, automated: bool) -> session::SessionModel { + if automated { + model.automation = vec![session::Automation { + id: session::StableId::parse("ride").expect("automation ID"), + target: session::ControlAddress { + owner: session::Owner::Strip(model.tracks[1].id.clone()), + section: session::Section::Builtins, + control: 5, + lane: session::ParameterChannel::Both, + }, + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: 0, + end_sample: 20_000, + start_value: 0.0, + end_value: SEEK_RIDE_DB, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + }]; + } else { + let fader = &mut model.tracks[1].fader; + (fader.left_db, fader.right_db) = (SEEK_RIDE_DB, SEEK_RIDE_DB); + } + model +} + +/// Renders `model` on the C ABI: ten blocks of generation 1 (after the removal transaction of +/// [`limited_session`] at block 6 when `floored`), a plain session seek to [`SEEK_TIMELINE`] and +/// eight blocks of generation 2 from there. Returns the output, block by block, and the render +/// sample the seek applied at (the source's seek report). +fn render_seek_ride_c( + model: &session::SessionModel, + floored: Option<&[protocol::SessionEdit; 2]>, +) -> (Vec>, u64) { + let document = session::canonical_session_json(model).expect("canonical"); + let (c_session, c_plan) = boxed_c_children(&document); + let (left, right) = continuity_block(0, 0); + submit_c(c_session, 1, 0, 48_000, &left, &right, false); + let mut blocks = feed_and_render_c(c_session, c_plan, 48_000, 0, 6); + if let Some(edits) = floored { + let latency = crate::ffi::test_plan_latency(c_plan); + assert!(latency > 0, "the limiter delays the plan"); + assert_eq!( + command_c(c_session, &transaction_at(1, 42, edits)).0, + crate::RESULT_OK + ); + blocks.extend(feed_and_render_c(c_session, c_plan, 48_000, 6, 4)); + assert_eq!( + crate::ffi::test_plan_latency(c_plan), + latency, + "#1285 floors" + ); + } else { + blocks.extend(feed_and_render_c(c_session, c_plan, 48_000, 6, 4)); + } + assert_eq!( + crate::ffi::test_session_seek(c_session, 2, SEEK_TIMELINE, None), + crate::RESULT_OK + ); + for step in 0..8_u64 { + let block = 10 + step; + let (left, right) = continuity_block(block + 1, 0); + submit_c( + c_session, + 2, + SEEK_TIMELINE + step * 128, + 48_000, + &left, + &right, + false, + ); + blocks.push(render_c(c_plan, block)); + } + let (generation, first, frame, _, _) = seek_report_c(c_session, b"fixture-source"); + assert_eq!((generation, frame), (2, SEEK_TIMELINE)); + if floored.is_some() { + assert!( + crate::ffi::test_plan_latency(c_plan) == 0, + "the plain seek's successor resets the floors" + ); + } + crate::ffi::test_plan_destroy(c_plan); + crate::ffi::test_session_destroy(c_session); + (blocks, first) +} + +/// #1647 gate 4 on the C ABI: after `miso_engine_v1_session_seek` to `T` on a ride that is flat +/// at `-9` dB around `T` and `0` dB where the session played before, the automated lane equals the +/// static fader at `-9` dB exactly from the render sample `r` the seek applied at (the fader has +/// no latency before it), with no ramp, and differs before it. Twice: on a plan with no raised +/// floor (the seek lands on the running plan as a `Set`), and on a plan whose floors #1285 raised, +/// where the plain seek publishes a discontinuity successor whose lanes start fresh (D2: the +/// restarted lane's first block takes a `Set`). +/// +/// Test value: red if a seek ramps the lane or reaches the fader at the wrong sample, or if a +/// restarted lane keeps its prepared value, on the C ABI. +#[test] +fn a_session_seek_sets_an_automated_fader_exactly_on_the_c_abi() { + let (limited, edits) = limited_session(); + for (label, base, floored) in [ + ("running plan", stateless_session(48_000), None), + ("raised floor", limited, Some(&edits)), + ] { + let (automated, r) = render_seek_ride_c(&seek_ride(base.clone(), true), floored); + let (reference, reference_r) = render_seek_ride_c(&seek_ride(base, false), floored); + assert_eq!(r, reference_r, "{label}"); + assert_eq!(r % 128, 0, "{label}: the seek applies at a block entry"); + let flat = |blocks: &[Vec], plane: usize| -> Vec { + blocks + .iter() + .flat_map(|block| block[plane * 128..(plane + 1) * 128].iter()) + .map(|value| value.to_bits()) + .collect() + }; + let r = r as usize; + for plane in 0..2 { + let (automated, reference) = (flat(&automated, plane), flat(&reference, plane)); + assert_eq!( + automated[r..], + reference[r..], + "{label}, plane {plane}: the static fader's bits from r" + ); + assert_ne!( + automated[r - 128..r], + reference[r - 128..r], + "{label}, plane {plane}: the lane was elsewhere before r" + ); + } + } +} diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 9b6df8365..79e2d8e0a 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1354,6 +1354,22 @@ impl<'a> GraphBlockTime<'a> { } // REALTIME_POLICY_END +/// Node `node`'s floored input arrival `a(n)` in `arrivals`, `GraphCompileReport::input_arrivals` +/// (#1285 D2: every node with a nonzero floored arrival, sorted by node ID): its row, or 0 for a +/// node absent from it. The one lookup both fader owners' installs take (#1647a, #1647b): a +/// builtin bank's members and the scalar oracle's bound fader nodes. +#[must_use] +pub fn floored_input_arrival( + arrivals: &[(GraphNodeId, LatencySamples)], + node: &GraphNodeId, +) -> u64 { + arrivals + .binary_search_by(|(row, _)| row.cmp(node)) + .ok() + .and_then(|row| arrivals.get(row)) + .map_or(0, |(_, arrival)| arrival.0) +} + /// What the plan's jump-length cell shares between its writer (control) and its reader (render): /// one #1312 latest-target cell and its dirty word (#1639 D1). struct JumpLengthLane { @@ -2123,13 +2139,7 @@ impl PreparedGraphPlan { let lanes: Vec = bank .members .iter() - .map(|member| { - arrivals - .binary_search_by(|(node, _)| node.cmp(member)) - .ok() - .and_then(|row| arrivals.get(row)) - .map_or(0, |(_, arrival)| arrival.0) - }) + .map(|member| floored_input_arrival(arrivals, member)) .collect(); bank.processor.set_input_arrivals(&lanes); } @@ -2965,6 +2975,14 @@ impl GraphNodeBinding { processor: None, } } + /// Give the bound processor its node's floored input arrival `a(n)` through + /// [`GraphRuntimeProcessor::set_input_arrival`] (#1647b); an identity binding has none. Off + /// the render thread, before bind. + pub fn set_input_arrival(&mut self, arrival: u64) { + if let Some(processor) = self.processor.as_mut() { + processor.set_input_arrival(arrival); + } + } } pub struct GraphBindingBlock<'a> { pub left: &'a mut [f32], @@ -3012,6 +3030,12 @@ pub trait GraphRuntimeSplitPairProcessor: Send + Any { /// The graph executor supplies a block shape validated by the prepared render quantum, so /// this operation cannot fail and never drains the matrix owner. fn complete_pending(&mut self, block: GraphBindingBlock<'_>); + + /// Take this block's node-time context before [`Self::begin_fader`] (#1647b), as + /// [`GraphRuntimeProcessor::set_block_time`] does for a bound node. The default keeps nothing. + fn set_block_time(&mut self, time: &GraphBlockTime<'_>) { + let _ = time; + } } /// Preparation-only constructor of a split scalar fader/matrix owner; test-only like @@ -3044,6 +3068,22 @@ pub trait GraphRuntimeProcessor: Send + Any { /// may read the block it is given. fn process(&mut self, block: GraphBindingBlock<'_>) -> Result<(), RenderError>; + /// Take the node's floored input arrival `a(n)` (#1647b, R17-4): the scalar oracle's bound + /// fader owners take it as a builtin bank takes its members' through + /// [`GraphPreparedBuiltinBankProcessor::set_input_arrivals`]. Called once, off the render + /// thread, before bind ([`GraphNodeBinding::set_input_arrival`]). The default keeps nothing. + fn set_input_arrival(&mut self, arrival: u64) { + let _ = arrival; + } + + /// Take this block's node-time context (#1647b, R17-4): the executor calls it on a bound node + /// right before [`Self::process`] of the same block, as it sets a bank's builtin slots before + /// their chain runs ([`GraphPreparedBuiltinBankProcessor::set_block_time`]). Render-thread + /// code: allocation-, lock- and syscall-free and bounded. The default keeps nothing. + fn set_block_time(&mut self, time: &GraphBlockTime<'_>) { + let _ = time; + } + /// Copy this scalar owner's retained response words without advancing render state. fn copy_response_snapshot( &self, diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index a46f8ab1b..b10c0ecec 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -3242,6 +3242,7 @@ impl Runtime { route_activity .as_deref_mut() .map(|activity| (activity, index)), + block_time, ) } RuntimeUnit::Bank { @@ -3279,6 +3280,7 @@ impl Runtime { snapshot, None, None, + block_time, )?; bank_inputs[lane] = member.output; } @@ -3654,6 +3656,7 @@ fn execute_op( snapshot: LiveSnapshot, mut host: Option>, routes: Option<(&mut RouteActivity, usize)>, + block_time: &crate::GraphBlockTime<'_>, ) -> Result<(), RenderError> { let output = op.output; if let NodeKind::TrackDelay { line, .. } = op.kind { @@ -3768,7 +3771,11 @@ fn execute_op( live_snapshot: snapshot, }; return match slot.role { - SplitPairRole::Fader => split_pairs[slot.pair].begin_fader(block), + SplitPairRole::Fader => { + // #1647b: the pair's fader half takes the block's node time first. + split_pairs[slot.pair].set_block_time(block_time); + split_pairs[slot.pair].begin_fader(block) + } SplitPairRole::Matrix => split_pairs[slot.pair].finish_matrix(block), }; } @@ -3789,6 +3796,8 @@ fn execute_op( } NodeKind::Bound(processor) => { let (out_left, out_right) = output_planes(arena, &mut host, output); + // #1647b (R17-4): a bound node takes the block's node time before it processes. + processor.set_block_time(block_time); processor.process(GraphBindingBlock { left: out_left, right: out_right, @@ -10144,6 +10153,7 @@ mod tests { LiveSnapshot::ALL, None, None, + &crate::GraphBlockTime::new(None, 0, None, None), ) .expect("earlier fader"); assert_eq!( @@ -10156,7 +10166,8 @@ mod tests { 0, LiveSnapshot::ALL, None, - None + None, + &crate::GraphBlockTime::new(None, 0, None, None), ), Err(RenderError::InvalidEnvelope) ); @@ -12018,6 +12029,7 @@ mod tests { LiveSnapshot::ALL, None, None, + &crate::GraphBlockTime::new(None, 0, None, None), ) .expect("op"); let (left, right) = arena.read_stereo(ARENA_BASE); diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index 34b36e2cc..dc588b667 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -3,7 +3,13 @@ //! the static bits) is on the two hosts: `crates/capi/src/runtime/tests.rs` and //! `hosts/host-web/src/tests.rs`. The render plumbing (#1647a): each automated fader lane takes //! its `a(n)` and, per block, its node-time mapping and the fader row's jump length, with no -//! render allocation. +//! render allocation. The events on the fader (#1647b): completion samples, jumps, latency, the +//! session seek, quantum invariance, allocation and the scalar oracle's parity (#1647 gates 1-6, +//! R17-4). + +#[cfg(feature = "test-support")] +#[path = "support/successor.rs"] +mod successor; use automation::ProgramBuilder; use builtins_compiler::fader_automation_sizes; @@ -430,3 +436,829 @@ fn each_automated_fader_lane_takes_its_arrival_and_node_time_per_block() { "the context-set path allocates and frees nothing" ); } + +// #1647b: the events on the fader. + +const NINE: &str = include_str!("../../../fixtures/session/v1/parametric-eq-nine-track.json"); +const NINE_SOURCE: &[u8] = b"fixture-source"; + +/// The first `tracks` tracks of the nine-track fixture at render quantum `quantum`: no console, +/// no insert, no input filter, each routed to the output, on the fixture's one source made long +/// enough for every test. On a DC source of `1.0` a track's output is its fader gain. +fn plain(tracks: usize, quantum: u32) -> SessionModel { + let mut model = parse_session_json(NINE).expect("fixture parses"); + model.console.pre_insert.clear(); + model.console.post_insert.clear(); + model.tracks.truncate(tracks); + let kept: Vec = model.tracks.iter().map(|track| track.id.clone()).collect(); + model.routes.retain(|route| match &route.source { + session::RouteSource::Track { track_id, .. } => kept.contains(track_id), + _ => false, + }); + for track in &mut model.tracks { + track.console.clear(); + track.inserts.effects.clear(); + for channel in [&mut track.builtins.left, &mut track.builtins.right] { + channel.hpf_hz = 0.0; + channel.lpf_hz = 0.0; + } + track.matrix_or_pan = session::MatrixOrPan::Matrix { + ll: 1.0, + lr: 0.0, + rl: 0.0, + rr: 1.0, + smoothing_samples: 0, + }; + } + model.sources[0].frames = 1 << 20; + model.quantum_frames = quantum; + model +} + +/// `control_smoothing` explicit with fader length `fader_ms` (mute and pan 5 ms). +fn smoothing(fader_ms: f32) -> Option { + Some(ControlSmoothing { + mute_ms: 5.0, + fader_ms, + pan_ms: 5.0, + }) +} + +/// A fader entry on `lane` of track `track` with `segments`. +fn fader_entry( + model: &SessionModel, + track: usize, + lane: ParameterChannel, + segments: Vec, +) -> Automation { + let mut entry = flat_fader(model, track, lane, 0.0); + entry.id = id(&format!("ride-{track}")); + entry.segments = segments; + entry +} + +/// A segment of `shape` from `start` to `end` and `start_value` to `end_value` dB. +fn segment( + shape: AutomationShape, + start: u64, + end: u64, + start_value: f32, + end_value: f32, +) -> AutomationSegment { + AutomationSegment { + shape, + start_sample: start, + end_sample: end, + start_value, + end_value, + unit: ParameterUnit::Db, + } +} + +/// The linear dB fade of gate 1: `-40` to `0` dB over 48,000 samples. +fn fade() -> Vec { + vec![segment(AutomationShape::Linear, 0, 48_000, -40.0, 0.0)] +} + +/// Steps at each `(sample, dB)` of `steps`, from `0` dB. +fn steps(steps: &[(u64, f32)]) -> Vec { + let mut from = (0_u64, 0.0_f32); + steps + .iter() + .map(|&(at, value)| { + let step = segment(AutomationShape::Step, from.0, at, from.1, value); + from = (at, value); + step + }) + .collect() +} + +/// The curve of `entry` at node time `t`, through the one evaluator render uses. +fn curve(entry: &Automation, t: i64) -> f32 { + automation::value_at(&controls::stored_segments(entry), t) +} + +/// The fader gain of `db` with no VCA (`S = +0.0`, #1646 D1). +fn gain(db: f32) -> f32 { + builtins::checked_fader_gain(session::vca_compose_db(db, 0.0)).expect("fader domain") +} + +/// The caps a [`Rig`] prepares under: a source ring of 32 quanta. +fn rig_caps(model: &SessionModel) -> HostPrepareCaps { + HostPrepareCaps { + source_ring_frames: 32 * model.quantum_frames, + ..caps(1_000_000_000) + } +} + +/// A prepared plan, rendered block by block on a DC source of `1.0`. +struct Rig { + prepared: PreparedHost, + handles: Option, + gate: Option, + quantum: usize, + /// The source generation and the source frame the next block submits. + generation: u64, + frame: u64, + rendered: u64, + out: [Vec; 2], + /// The render calls' allocations, reallocations and frees since the last take. + allocations: (u64, u64, u64), +} + +impl Rig { + fn from(prepared: PreparedHost, model: &SessionModel) -> Self { + Self { + prepared, + handles: None, + gate: None, + quantum: model.quantum_frames as usize, + generation: 1, + frame: 0, + rendered: 0, + out: [Vec::new(), Vec::new()], + allocations: (0, 0, 0), + } + } + + fn new(model: &SessionModel) -> Self { + let document = canonical_session_json(model).expect("canonical"); + let prepared = prepare_host_session(&document, &rig_caps(model)) + .map(|(_, prepared)| prepared) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + Self::from(prepared, model) + } + + /// With live controls on every strip and the plan's revision gate, for a jump-length write. + fn live(model: &SessionModel) -> Self { + let document = canonical_session_json(model).expect("canonical"); + let compiled = host_core::compile_host_session(&document, &rig_caps(model)) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + let (mut prepared, handles) = host_core::prepare_host_runtime_with_live_lanes( + &compiled, + &rig_caps(model), + &host_core::HostLiveControlRequest::default(), + host_core::HostLiveLanes::ALL, + &host_core::LiveValuesSuperseded::default(), + ) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + let gate = prepared.plan.take_revision_gate(); + let mut rig = Self::from(prepared, model); + rig.handles = Some(handles); + rig.gate = gate; + rig + } + + /// Write `fader` samples as the plan's fader jump length (mute and pan unchanged), applied + /// at the next block entry (#1639). + fn write_fader_jump(&mut self, model: &SessionModel, fader: u32) { + use builtins_compiler::TestOnlyWriteGate; + let mut lengths = host_core::session_jump_lengths(model); + lengths.fader_samples = fader; + let producer = self + .handles + .as_mut() + .and_then(|handles| handles.jump_lengths.as_mut()) + .expect("the plan's jump-length cell"); + self.gate + .as_mut() + .expect("the plan's gate") + .write_published(|stamp| producer.write(stamp, lengths)); + } + + /// A plain session seek to timeline sample `timeline` (#1641): the source's next PCM is the + /// new generation's, from that frame. + fn seek(&mut self, timeline: u64) { + self.generation += 1; + self.prepared + .sources + .seek_session(self.generation, timeline, None) + .expect("the seek is admitted"); + self.frame = timeline; + } + + /// Render one block; returns its first render sample. + fn block(&mut self) -> u64 { + let quantum = self.quantum; + let plane = vec![1.0_f32; quantum]; + self.prepared + .sources + .submit( + NINE_SOURCE, + SourceSubmission { + generation: self.generation, + start_frame: self.frame, + sample_rate_hz: RATE, + planes: &[&plane, &plane], + frames: quantum as u32, + end_of_region: false, + }, + ) + .expect("source block"); + self.frame += quantum as u64; + let mut samples = vec![0.0_f32; quantum * 2]; + let output = + PlanarBufferMut::try_new(&mut samples, 2, quantum, quantum).expect("output planes"); + let first = self.rendered; + let mark = bench_support::alloc::current_thread_counters(); + self.prepared + .plan + .render( + RenderIo { output }, + RenderTime { + absolute_sample: first, + }, + ) + .expect("render"); + let delta = bench_support::alloc::current_thread_delta_since(mark); + self.allocations.0 += delta.allocations; + self.allocations.1 += delta.reallocations; + self.allocations.2 += delta.deallocations; + self.out[0].extend_from_slice(&samples[..quantum]); + self.out[1].extend_from_slice(&samples[quantum..]); + self.rendered += quantum as u64; + first + } + + /// Render until at least `frames` samples are out. + fn until(&mut self, frames: u64) { + while self.rendered < frames { + self.block(); + } + } +} + +/// The stage driven directly: a width-one ramped fader from `start` dB, given each +/// `(sample, operation)` of `operations` at its sample (#1638), over `frames` samples of DC `1.0`. +fn reference(start: f32, operations: &[(u64, builtins::FaderOperation)], frames: u64) -> Vec { + let mut stage = builtins::FaderMuteRampBuiltins::new(builtins::BuiltinParameters::default()) + .expect("default parameters"); + stage + .apply_fader_operation( + builtins::BuiltinLaneSelector::Both, + builtins::FaderOperation::SetGain { gain: gain(start) }, + ) + .expect("prepared gain"); + let mut out = Vec::with_capacity(frames as usize); + let mut next = operations.iter().peekable(); + let mut at = 0_u64; + while at < frames { + while let Some(&&(sample, operation)) = next.peek() + && sample == at + { + stage + .apply_fader_operation(builtins::BuiltinLaneSelector::Both, operation) + .expect("operation"); + next.next(); + } + let end = next + .peek() + .map_or(frames, |&&(sample, _)| sample.min(frames)); + let length = (end - at) as usize; + let mut left = vec![1.0_f32; length]; + let mut right = vec![1.0_f32; length]; + stage.process(builtins::DualMonoBlock::new(&mut left, &mut right, at).expect("block")); + out.extend_from_slice(&left); + at = end; + } + out +} + +/// The grid retargets of a curve with no discontinuity, from node time 0 to `frames`, as #1637 +/// emits them on a cell seeded with the curve's first value: at each `64k`, toward the curve at +/// `64k + 63` over 64 samples, when that value changes. +fn grid_operations(entry: &Automation, frames: u64) -> Vec<(u64, builtins::FaderOperation)> { + let mut held = curve(entry, 0); + (0..frames) + .step_by(64) + .filter_map(|at| { + let value = curve(entry, at as i64 + 63); + (value.to_bits() != held.to_bits()).then(|| { + held = value; + ( + at, + builtins::FaderOperation::RetargetGain { + gain: gain(value), + samples: 64, + }, + ) + }) + }) + .collect() +} + +/// The first sample index at which `a` and `b` differ by bits. +fn first_difference(a: &[f32], b: &[f32]) -> Option { + a.iter() + .zip(b) + .position(|(a, b)| a.to_bits() != b.to_bits()) +} + +/// #1647 gate 1: a linear dB fade from `-40` to `0` dB over 48,000 samples on a DC source of +/// `1.0`, `control_smoothing` explicit 0. At each completion sample `64k + 63` the output equals +/// `checked_fader_gain(curve(64k + 63))` exactly, and every sample equals the stage driven +/// directly with the grid's retargets (the ramp kernel's interpolation between them). +/// +/// Test value: red if a completion is off by one, if the grid follows render samples instead of +/// node time, if an event's gain is not composed and converted as the static fader's, or if the +/// first grid event is lost to the seed. +#[test] +fn a_fader_fade_reaches_the_curve_at_each_completion_sample() { + const FRAMES: u64 = 48_000 + 1_024; + let mut model = plain(1, 128); + model.control_smoothing = smoothing(0.0); + let entry = fader_entry(&model, 0, ParameterChannel::Both, fade()); + model.automation = vec![entry.clone()]; + let mut rig = Rig::new(&model); + rig.until(FRAMES); + for k in 0..FRAMES / 64 { + let t = 64 * k + 63; + for plane in &rig.out { + assert_eq!( + plane[t as usize].to_bits(), + gain(curve(&entry, t as i64)).to_bits(), + "completion sample {t}" + ); + } + } + let expected = reference(-40.0, &grid_operations(&entry, FRAMES), FRAMES); + for plane in &rig.out { + assert_eq!( + first_difference(&plane[..FRAMES as usize], &expected), + None, + "every sample is the ramp kernel's" + ); + } +} + +/// #1647 gate 2: a `step` from `0` to `-6` dB at sample 1,000 (not a grid sample). With +/// `control_smoothing` explicit 0 the output changes exactly at sample 1,000; with the default +/// table the ramp starts at 1,000 and ends at `1,000 + J - 1`, `J` the session's fader length. +/// +/// Test value: red if a jump snaps to the grid, ignores `control_smoothing`, starts a sample late +/// or early, or completes off by one. +#[test] +fn a_fader_step_jumps_at_its_exact_sample() { + const FRAMES: u64 = 4_096; + for fader_ms in [None, Some(0.0)] { + let mut model = plain(1, 128); + model.control_smoothing = fader_ms.and_then(smoothing); + let ramp = controls::LiveRamps::for_session(&model).fader_samples; + model.automation = vec![fader_entry( + &model, + 0, + ParameterChannel::Both, + steps(&[(1_000, -6.0)]), + )]; + let mut rig = Rig::new(&model); + rig.until(FRAMES); + let expected = reference( + 0.0, + &[( + 1_000, + builtins::FaderOperation::RetargetGain { + gain: gain(-6.0), + samples: ramp, + }, + )], + FRAMES, + ); + for plane in &rig.out { + assert_eq!(plane[999].to_bits(), 1.0_f32.to_bits(), "{fader_ms:?}"); + assert_ne!(plane[1_000].to_bits(), 1.0_f32.to_bits(), "{fader_ms:?}"); + let end = 1_000 + ramp.max(1) as usize - 1; + assert_eq!(plane[end].to_bits(), gain(-6.0).to_bits(), "{fader_ms:?}"); + if ramp > 1 { + assert_ne!( + plane[end - 1].to_bits(), + gain(-6.0).to_bits(), + "{fader_ms:?}" + ); + } + assert_eq!( + first_difference(&plane[..FRAMES as usize], &expected), + None, + "{fader_ms:?}: the stage driven directly" + ); + } + } +} + +/// #1647 gate 2, the next jump and not the one in flight: steps at 1,000 and 3,000 with a fader +/// length of 480; a write of 960 to the plan's jump-length cell (#1639) at the block entry at +/// 1,152, while the first jump ramps. The first completes over 480 and the second ramps over 960, +/// bit for bit the stage driven directly with those two retargets. +/// +/// Test value: red if a written length cuts the jump in flight, or if the length is read only at +/// preparation or a block late. +#[test] +fn a_written_jump_length_reaches_the_next_jump_not_the_one_in_flight() { + const FRAMES: u64 = 4_608; + let mut model = plain(1, 128); + model.control_smoothing = smoothing(10.0); + assert_eq!(controls::LiveRamps::for_session(&model).fader_samples, 480); + model.automation = vec![fader_entry( + &model, + 0, + ParameterChannel::Both, + steps(&[(1_000, -6.0), (3_000, -12.0)]), + )]; + let mut rig = Rig::live(&model); + while rig.rendered < 1_152 { + rig.block(); + } + rig.write_fader_jump(&model, 960); + rig.until(FRAMES); + let retarget = |db: f32, samples: u32| builtins::FaderOperation::RetargetGain { + gain: gain(db), + samples, + }; + let expected = reference( + 0.0, + &[(1_000, retarget(-6.0, 480)), (3_000, retarget(-12.0, 960))], + FRAMES, + ); + for plane in &rig.out { + assert_eq!(first_difference(&plane[..FRAMES as usize], &expected), None); + assert_eq!(plane[1_479].to_bits(), gain(-6.0).to_bits()); + assert_eq!(plane[3_959].to_bits(), gain(-12.0).to_bits()); + assert_ne!(plane[3_958].to_bits(), gain(-12.0).to_bits()); + } +} + +/// #1647 gate 3: two tracks on one source, the first behind a true-peak limiter (latency `λ`) +/// before its fader, each with a fader step at timeline 4,800 (`control_smoothing` explicit 0). +/// The event probe puts each step at render sample `4,800 + a(n)`, so the two are `λ` apart, and +/// in the output both steps meet the source frame 4,800 they act on: the output equals the +/// session with both faders at `0` dB before that frame's output sample and the session with both +/// at `-6` dB from it. +/// +/// Test value: red if node time ignores `a(n)`: the latent track's automation would lead its +/// audio by `λ`. +#[test] +fn a_fader_step_meets_its_source_frame_through_a_latent_insert() { + const FRAMES: u64 = 8_192; + let mut base = with_limiter(plain(2, 128), 0); + base.control_smoothing = smoothing(0.0); + let mut automated = base.clone(); + automated.automation = (0..2) + .map(|track| { + fader_entry( + &automated, + track, + ParameterChannel::Both, + steps(&[(4_800, -6.0)]), + ) + }) + .collect(); + let mut low = base.clone(); + for track in &mut low.tracks { + (track.fader.left_db, track.fader.right_db) = (-6.0, -6.0); + } + + let mut rig = Rig::new(&automated); + let latent = fader_arrival(&rig.prepared, &automated, 0); + let plain_arrival = fader_arrival(&rig.prepared, &automated, 1); + assert!(latent > 0 && plain_arrival == 0, "{latent} {plain_arrival}"); + let _ = builtins_compiler::test_only_take_fader_events(); + rig.until(FRAMES); + let (events, overflow) = builtins_compiler::test_only_take_fader_events(); + assert!(!overflow); + let mut samples: Vec = events.iter().map(|event| event.sample).collect(); + samples.sort_unstable(); + samples.dedup(); + assert_eq!(samples, [4_800, 4_800 + latent], "{events:?}"); + + let mut high = Rig::new(&base); + high.until(FRAMES); + let mut low = Rig::new(&low); + low.until(FRAMES); + let reach = high.out[0] + .iter() + .position(|value| *value != 0.0) + .expect("the source reaches the output") as u64; + let step = (4_800 + reach) as usize; + for plane in 0..2 { + assert_eq!( + first_difference(&rig.out[plane][..step], &high.out[plane][..step]), + None, + "plane {plane}: before the step" + ); + assert_eq!( + first_difference( + &rig.out[plane][step..FRAMES as usize], + &low.out[plane][step..FRAMES as usize] + ), + None, + "plane {plane}: from the step" + ); + } +} + +/// #1647 gate 4 (host-core): after a session seek to `T` through #1641's producer, the seek +/// reaches the fader at the render sample `r` of the block it applies at (no latency here), and +/// the lane equals `checked_fader_gain(curve(T))` exactly from `r`, with no ramp: the probe shows +/// one `Set` at `r` with the curve's value. +/// +/// Test value: red if a seek ramps the lane, lands at the wrong sample, or the cell repositions +/// without a `Set`. +#[test] +fn a_session_seek_sets_the_fader_lane_exactly() { + const T: u64 = 24_000; + let mut model = plain(1, 128); + let entry = fader_entry(&model, 0, ParameterChannel::Both, fade()); + model.automation = vec![entry.clone()]; + let mut rig = Rig::new(&model); + rig.until(1_280); + let _ = builtins_compiler::test_only_take_fader_events(); + rig.seek(T); + let r = rig.block(); + let (events, _) = builtins_compiler::test_only_take_fader_events(); + let sets: Vec<_> = events + .iter() + .filter(|event| event.kind == automation::EventKind::Set) + .map(|event| (event.channel, event.sample, event.value.to_bits())) + .collect(); + let value = curve(&entry, T as i64).to_bits(); + assert_eq!(sets, [(0, r, value), (1, r, value)], "{events:?}"); + for plane in &rig.out { + assert_eq!( + plane[r as usize].to_bits(), + gain(curve(&entry, T as i64)).to_bits(), + "exact at r" + ); + } +} + +/// The output of `model` at `quantum`, over `frames`, with an optional session seek to timeline +/// `seek.1` pushed before the block that starts at render sample `seek.0`. +fn render_at( + model: &SessionModel, + quantum: u32, + frames: u64, + seek: Option<(u64, u64)>, +) -> [Vec; 2] { + let mut model = model.clone(); + model.quantum_frames = quantum; + let mut rig = Rig::new(&model); + while rig.rendered < frames { + if let Some((at, timeline)) = seek + && rig.rendered == at + { + rig.seek(timeline); + } + rig.block(); + } + rig.out.map(|mut plane| { + plane.truncate(frames as usize); + plane + }) +} + +/// #1647 gate 5: the same session at quantum 128 and at quantum 100 gives the same output bits, +/// sample for sample from sample 0, with and without a session seek (at render sample 3,200, a +/// block entry at both quanta). The ride holds a fade, steps and the default jump length. +/// +/// Test value: red if any value depends on the quantum (README blocker B1): a grid on render +/// blocks, a jump or a seek landing on a block boundary. +#[test] +fn the_fader_events_do_not_depend_on_the_quantum() { + const FRAMES: u64 = 9_600; + let mut model = plain(1, 128); + let mut segments = vec![segment(AutomationShape::Linear, 0, 2_000, -30.0, -3.0)]; + segments.push(segment(AutomationShape::Step, 2_000, 4_321, -3.0, -18.0)); + segments.push(segment(AutomationShape::Linear, 4_321, 9_000, -18.0, 2.0)); + model.automation = vec![fader_entry(&model, 0, ParameterChannel::Left, segments)]; + for seek in [None, Some((3_200, 7_777))] { + let a = render_at(&model, 128, FRAMES, seek); + let b = render_at(&model, 100, FRAMES, seek); + for plane in 0..2 { + assert_eq!( + first_difference(&a[plane], &b[plane]), + None, + "{seek:?} plane {plane}" + ); + } + assert_ne!(a[0], a[1], "the left lane moves"); + } +} + +/// #1647 gate 6: 1,000 blocks with events on eight lanes (eight tracks, each a fade with steps on +/// its left lane) allocate and free nothing after a warm-up. +/// +/// Test value: red if render allocates for events. +#[test] +fn rendering_fader_events_allocates_nothing() { + let mut model = plain(8, 128); + model.automation = (0..8) + .map(|track| { + let mut segments = vec![segment( + AutomationShape::Linear, + 0, + 60_000, + -40.0 + track as f32, + 0.0, + )]; + segments.push(segment(AutomationShape::Step, 60_000, 90_000, 0.0, -12.0)); + segments.push(segment( + AutomationShape::Linear, + 90_000, + 128_000, + -12.0, + -40.0, + )); + fader_entry(&model, track, ParameterChannel::Left, segments) + }) + .collect(); + let mut rig = Rig::new(&model); + rig.until(4 * 128); + let _ = builtins_compiler::test_only_take_fader_events(); + rig.allocations = (0, 0, 0); + let mut lanes = std::collections::BTreeSet::new(); + for _ in 0..1_000 { + rig.block(); + let (events, _) = builtins_compiler::test_only_take_fader_events(); + lanes.extend(events.iter().map(|event| (event.lane, event.channel))); + } + assert_eq!(lanes.len(), 8, "events on eight lanes: {lanes:?}"); + assert_eq!(rig.allocations, (0, 0, 0), "1,000 blocks of fader events"); +} + +/// The session of the oracle parity test: a fade on both lanes of track 0, steps on the left lane +/// and a fade on the right lane of track 1, no automation on track 2; the default jump length. +#[cfg(feature = "test-support")] +fn parity_model() -> SessionModel { + let mut model = plain(3, 128); + let mut right = fade(); + right[0].start_value = -12.0; + model.automation = vec![ + fader_entry(&model, 0, ParameterChannel::Both, fade()), + fader_entry( + &model, + 1, + ParameterChannel::Left, + steps(&[(700, -6.0), (2_900, -24.0), (5_000, 3.0)]), + ), + { + let mut entry = fader_entry(&model, 1, ParameterChannel::Right, right); + entry.id = id("ride-1-right"); + entry + }, + ]; + model +} + +/// Render `rig` for `blocks` blocks; with handles, a live mute ramp of 300 samples starts on track +/// 1's left lane at block 10 and an unmute ramp at block 20, so a held ramp is reported (D1.2). +#[cfg(feature = "test-support")] +fn render_parity(mut rig: Rig, blocks: usize) -> [Vec; 2] { + use builtins_compiler::TestOnlyWriteGate; + let mut gate = rig.handles.is_some().then(|| { + rig.prepared + .plan + .take_revision_gate() + .expect("the plan's gate") + }); + for block in 0..blocks { + if let (Some(gate), Some(handles)) = (gate.as_mut(), rig.handles.as_mut()) + && (block == 10 || block == 20) + { + gate.write_published(|stamp| { + handles.strip_controls[1].fader.write( + stamp, + builtins_compiler::TrackFaderRecord::Mute { + lanes: builtins::BuiltinLaneSelector::Left, + muted: block == 10, + smoothing_samples: 300, + }, + ); + }); + } + rig.block(); + } + rig.out +} + +/// R17-4: the test-only scalar oracle renders the stored fader events through the one event +/// driver, so a `Backend::Scalar` plan renders the bank's bits: with no live control (the oracle's +/// automated strip binds the ramped fader), with live controls (the live fader owner and its pair +/// with the matrix), and prepared between render calls (the split pair), each with a live mute +/// and unmute ramp on an automated lane. +/// +/// Test value: red if the oracle's fader owners ignore the program, take no node time or `a(n)`, +/// defer or fuse an automated fader past its events, or convert an event differently from the +/// bank: "a placement change moves no bit" would fail for automated sessions. +#[cfg(feature = "test-support")] +#[test] +fn the_scalar_oracle_renders_the_banks_fader_events() { + use graph_compiler::Backend; + const BLOCKS: usize = 64; + let model = parity_model(); + let document = canonical_session_json(&model).expect("canonical"); + let caps = rig_caps(&model); + let compiled = host_core::compile_host_session(&document, &caps) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + let request = host_core::HostLiveControlRequest::default(); + let plain_on = |backend| { + let prepared = host_core::test_only_prepare_host_runtime_on(&compiled, &caps, backend) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + render_parity(Rig::from(prepared, &model), BLOCKS) + }; + let live_on = |backend, between: bool| { + let (prepared, handles) = if between { + host_core::prepare::test_only_prepare_host_runtime_between_render_calls_on( + &compiled, &caps, &request, backend, + ) + } else { + host_core::test_only_prepare_host_runtime_with_live_controls_on( + &compiled, &caps, &request, backend, + ) + } + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + let mut rig = Rig::from(prepared, &model); + rig.handles = Some(handles); + render_parity(rig, BLOCKS) + }; + let mut unautomated = model.clone(); + unautomated.automation.clear(); + let without = render_parity(Rig::new(&unautomated), BLOCKS); + for (label, bank, oracle) in [ + ( + "no live control", + plain_on(Backend::Simd4), + plain_on(Backend::Scalar), + ), + ( + "live controls", + live_on(Backend::Simd4, false), + live_on(Backend::Scalar, false), + ), + ( + "between render calls", + live_on(Backend::Simd4, true), + live_on(Backend::Scalar, true), + ), + ] { + assert_ne!(bank, without, "{label}: the automation is heard"); + for plane in 0..2 { + assert_eq!( + first_difference(&bank[plane], &oracle[plane]), + None, + "{label}: plane {plane}" + ); + } + } +} + +/// #1647 D2 (departure 4): a lane a successor adds starts at its prepared value, the curve's +/// first value, and its first block lands a `Set` when the curve at that block's node time +/// differs. Track 1 gains a fader step from `0` to `-9` dB at timeline 300 in a successor swapped +/// in at block 6 (render sample 768, no seek): from the swap block the output equals, bit for bit, +/// the successor's session rendered fresh from sample 0, whose lane took the jump at 300 (over +/// 240 samples) and holds `-9` dB. +/// +/// Test value: red if a restarted or added lane keeps its prepared value into the successor (it +/// would glide from `0` dB on the first grid event instead of standing at the curve). +#[cfg(feature = "test-support")] +#[test] +fn a_lane_a_successor_adds_starts_at_its_curve() { + use successor::{Feed, SWAP_BLOCK, Session, Successor, reference_run, swapped_run}; + let mut base = plain(2, 128); + // A 240-sample jump: the fresh reference's ramp at 300 completes before the swap block. + base.control_smoothing = smoothing(5.0); + let mut ride = base.clone(); + ride.automation = vec![fader_entry( + &ride, + 1, + ParameterChannel::Both, + steps(&[(300, -9.0)]), + )]; + let a = Session::compile(base); + let b = Session::compile(ride); + let feed = Feed::new("fixture-source", 7); + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + let swapped = swapped_run(&a, &b, &[&feed], &[&feed], backend, Successor::Carry); + let reference = reference_run(&b, &[&feed], backend); + let without = reference_run(&a, &[&feed], backend); + for (block, (swapped, reference)) in swapped.blocks[SWAP_BLOCK..] + .iter() + .zip(&reference[SWAP_BLOCK..]) + .enumerate() + { + assert_eq!( + swapped.bits, + reference.bits, + "{backend:?}: block {}", + SWAP_BLOCK + block + ); + } + assert_ne!( + reference[SWAP_BLOCK].bits, without[SWAP_BLOCK].bits, + "the ride is heard" + ); + } +} diff --git a/docs/REALTIME_MEMORY.md b/docs/REALTIME_MEMORY.md index 6515350c0..ab0a6cee6 100644 --- a/docs/REALTIME_MEMORY.md +++ b/docs/REALTIME_MEMORY.md @@ -10,7 +10,7 @@ channel/frame multiplication and accumulated offset. The borrowed `PlanarBufferM slice and scalar shape metadata only. Issue 003 promises contiguous planar `f32`; SIMD alignment and AoSoA layout belong to issue 008. -Live parameter delivery is owned by the per-effect `EffectControlLane` (#140 A), fed by the `EffectControlProducer`s prepared with the plan; the protocol crate keeps no automation queue (#1629 deleted it; see `CONTROL_PROTOCOL_SEMANTICS.md`, "Delivery history"). Rendering the session's stored automation is owned by #1058, and live fader, mute and pan on the C ABI by #1053 through the same live-control lanes. The plan itself carries no parameter store. (#84 phase C deleted the unused issue-003 slot/event +Live parameter delivery is owned by the per-effect `EffectControlLane` (#140 A), fed by the `EffectControlProducer`s prepared with the plan; the protocol crate keeps no automation queue (#1629 deleted it; see `CONTROL_PROTOCOL_SEMANTICS.md`, "Delivery history"). Stored fader automation is a compiled table in the plan (#1646, #1647; the rest of the session's stored automation is owned by #1058): render generates its events from the plan, with no queue. Live fader, mute and pan on the C ABI are owned by #1053, through the same live-control lanes. The plan itself carries no parameter store. (#84 phase C deleted the unused issue-003 slot/event store; `PlanEpoch` now lives with the plan exchange, whose publication epochs it names.) `PreparedRenderPlan` privately separates immutable `PreparedProgram`/`RenderEnvelope` from mutable diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index 5eff0c047..daa581ea4 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -283,11 +283,17 @@ segment whose unit is not the row's, or a value the row refuses), `control.autom addresses that submix's entry for the slot (#1202). A session does not have to state a parameter value for the row it automates: the row exists whether or not the session states it. -**The automation table is consumed by nothing today.** No lowering reads it, for the strip, a -console slot or an insert: a valid target is valid-and-inert syntax that authors, round-trips -and renders nothing. Extending the vocabulary unblocks authoring and the SDK's builder. Rendering -the stored automation table, builtin targets included, identically on every platform is owned by -issue #1058 (research first); #140, which once gated it, was descoped +**The fader row renders; every other row is inert today.** An entry on a strip's fader +(`builtins` row 5) is compiled into the plan as a table (#1646) and rendered on the C ABI and in +the browser alike (#1647): the lane follows its curve in node time, reaching the curve's value at +the end of each 64-sample retarget on a fixed grid, taking each discontinuity at its exact sample +over the session's fader ramp length (`control_smoothing.fader_ms`), lined up with the audio it +acts on through every latent insert, and set exactly, with no ramp, where a session seek reaches +the fader. The output does not depend on the render quantum or on where a seek landed. Stored +automation has no queue: render generates its events from the plan. An entry on any other row, +for the strip, a console slot or an insert, is valid and inert: it authors and round-trips and +renders nothing until its row's slice lands. Rendering the whole table identically on every +platform is owned by issue #1058; #140, which once gated it, was descoped (`rulings/engine-footprint-2026-09-28.md`). **What the table means: the hold rule (#1626, #1058 A3).** One entry is one curve of one lane over diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 17e5418a8..3270ee800 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -9967,6 +9967,77 @@ mod session_seek { assert_eq!(rig.landed(ADDED), (2, swap, 3_000)); assert_eq!(rig.timeline(swap), 3_000); } + + /// `one_source()` with track `0`'s fader on a ride (`automated`): `0` dB before timeline + /// 2,000 and `-9` dB from it on; or, as the reference, its static fader at `-9` dB. + fn seek_ride(automated: bool) -> SessionModel { + let mut model = one_source(); + if automated { + model.automation = vec![session::Automation { + id: StableId::parse("ride").expect("automation ID"), + target: session::ControlAddress { + owner: session::Owner::Strip(model.tracks[0].id.clone()), + section: session::Section::Builtins, + control: 5, + lane: session::ParameterChannel::Both, + }, + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: 0, + end_sample: 2_000, + start_value: 0.0, + end_value: -9.0, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + }]; + } else { + let fader = &mut model.tracks[0].fader; + (fader.left_db, fader.right_db) = (-9.0, -9.0); + } + model + } + + /// Boot the ride, seek to timeline 6,000 through `miso_engine_web_v1_session_seek` after the + /// four primed blocks, and render six blocks of generation 2: the blocks before and from the + /// seek, and the render sample the seek applied at. + fn seek_ride_blocks(automated: bool) -> (Vec>, u64) { + let mut rig = Rig::boot(&seek_ride(automated), &[LONG]); + rig.feed(1, rig.next()); + let mut blocks = vec![rig.render()]; + assert_eq!( + miso_engine_web_v1_session_seek(rig.handle, 2, 6_000), + RESULT_OK + ); + for step in 0..6 { + rig.feed(2, 6_000 + step * Q); + blocks.push(rig.render()); + } + let (generation, first, frame) = rig.landed(LONG); + assert_eq!((generation, frame), (2, 6_000)); + (blocks, first) + } + + /// #1647 gate 4, the browser leg (decision 17 C1): after `miso_engine_web_v1_session_seek` to + /// a timeline where the ride is flat at `-9` dB, the automated fader renders the static `-9` + /// dB fader's bits from the block the seek applied at, with no ramp, and the `0` dB of the + /// ride's start before it: the same core seek as `miso_engine_v1_session_seek`. + /// + /// Test value: red if the browser's seek ramps an automated lane, lands it at another block, + /// or reaches the fader without moving it. + #[test] + fn a_session_seek_sets_an_automated_fader_exactly_in_the_browser() { + let (automated, r) = seek_ride_blocks(true); + let (reference, reference_r) = seek_ride_blocks(false); + assert_eq!(r, reference_r); + assert_eq!(r, 5 * Q, "the seek applies at the block after it"); + assert_ne!(automated[0], reference[0], "the ride starts at 0 dB"); + assert_eq!( + automated[1..], + reference[1..], + "the static fader's bits from r" + ); + } } /// #1646 gate 1 (browser): the parity fixture's first three tracks, with empty console sections, From fdea587de3d2aac7389cf8167dcb1ffb20bd55ef Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 19:08:14 +0000 Subject: [PATCH 14/35] Compose live level values inside CanonicalFpEnv (TBD48, R17-7) One RAII guard at each control-thread entry that composes or converts level values: SessionState::command (live classification, staging, rebuild) and declare_discontinuity_serviced, control-plane compile_children (boot), host-core ControlCatalog::build and stage_live, and the whole of host-core prepare_host_runtime_* (route and send gains, effect coefficients, successor joins). Gates: a live fader and VCA move under a hostile caller word (FTZ, DAZ, round toward zero) render the canonical bits on the C ABI and the browser; the preparation test now has non-0 dB routes. The hostile-word helper lives in lane::fpenv behind test-support, so no test file holds unsafe (fixes the realtime policy failure the U1 fold introduced). pcm_digest unchanged. Co-Authored-By: Claude Opus 5.5 --- ...ues-inside-the-canonical-fp-environment.md | 94 ++++++++- Cargo.lock | 1 + crates/capi/Cargo.toml | 2 + crates/capi/src/runtime/live_tests.rs | 127 ++++++++++++ crates/control-plane/Cargo.toml | 2 + crates/control-plane/src/compile.rs | 4 + crates/control-plane/src/control.rs | 12 ++ crates/host-core/Cargo.toml | 2 + crates/host-core/src/control_provider.rs | 6 + crates/host-core/src/prepare.rs | 6 + .../tests/stored_fader_automation.rs | 96 ++++------ crates/lane/src/fpenv.rs | 48 +++++ hosts/host-web/Cargo.toml | 5 +- hosts/host-web/src/tests.rs | 181 ++++++++++++++++++ 14 files changed, 527 insertions(+), 59 deletions(-) diff --git a/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md b/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md index e5cc5abae..23676de53 100644 --- a/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md +++ b/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md @@ -8,7 +8,12 @@ it a number. A live fader, VCA or trim move gives the same gain bits as a rebuild or a fresh preparation of the same committed value, whatever floating-point environment the host's control thread runs in. Every dB composition and dB-to-gain conversion that the control plane does for a live value runs inside -`lane::CanonicalFpEnv`, on both hosts, as preparation does since the U1 fold of #1646. +`lane::CanonicalFpEnv`, on both hosts, as preparation does since the U1 fold of #1646. The same +holds for every other live level record the control thread lowers: pan/matrix records +(`matrix_record`, `lower_matrix_or_pan`), send and route records (`route_record_between`, whose +`route_coefficients` converts the route gain) and input-filter records (`input_records`); and for +the whole preparation (route and send gains converted by graph-compiler's `compile_graph`, native +effect coefficients). ## Context @@ -24,6 +29,13 @@ dB composition and dB-to-gain conversion that the control plane does for a live - the live setters that call `checked_fader_gain` when a record is written to a cell; - `crates/host-core/src/control_provider.rs:1199` and `:1573` (`controls::effective` for the catalog's derived rows; display only, but it must agree with render). + - the other live level records the classifier lowers on the control thread: pan/matrix + (`prepare.rs` `matrix_record` / `lower_matrix_or_pan`), sends and routes + (`route_record_between` -> `route_controls::route_record` -> `route_coefficients`) and input + filters (`input_records`); + - host-core's preparation outside the inner #1646 guards: `compile_graph`'s route gains + (`graph-compiler` `route_values` -> `math::db_to_gain_f32`) and native effect coefficient + preparation (coordinator addition, U1 fold verification). - README finding F11 of #1058 (preparation runs in the host's environment) is the same hazard. - On a canonical host (every test host today) nothing differs, so no test is red today. @@ -41,3 +53,83 @@ dB composition and dB-to-gain conversion that the control plane does for a live - #1646 (U1 fold: the preparation guard and its test pattern), #1649, #1650b (their live writes join the gate). + +## Decision record: TBD48 (batch 17) + +Root ruling R17-7: inside batch 17, closes in this PR. + +- **D1, where the guard sits.** One RAII `lane::CanonicalFpEnv` at each core control entry, none in + a host adapter: + - `SessionState::command` (`crates/control-plane/src/control.rs`), for the whole call: the + service step, the protocol preparation, the live classification (`classify_live_edit` / + `classify_live_staged`, whose `EffectiveDiff` / `LocalDiff` compose the effective fader and VCA + sum), the catalog's `stage_live` and every rebuild. Both hosts reach a transaction only + through it: the C ABI's `miso_engine_v1_submit_command` and the browser's + `control_command` (Worker and single-instance alike, `run_command` -> `command`). + - `SessionState::declare_discontinuity_serviced`: the one core path of + `declare_discontinuity` and of a plain `session_seek` on both hosts; its successor's carried + level values are composed under it. + - `compile_children` (`crates/control-plane/src/compile.rs`): the boot of both hosts (catalog + build and boot preparation). + - host-core `ControlCatalog::build` and `ControlCatalog::stage_live` + (`crates/host-core/src/control_provider.rs`): the catalog's derived rows are composed in the + canonical environment whatever the caller (`prepare_host_runtime*` builds the catalog outside + the #1646 preparation guard). + The dB-to-gain conversion of a live fader record is not on the control thread: the record + carries dB and render converts it (`set_fader_db` in `drain_fader_controls`), under render's own + pinned environment. So the control thread's only environment-dependent level arithmetic is the + composition, and the guard covers it. + Every return path restores the caller's word (RAII `Drop`, errors and unwinding included). + Nesting is safe: an inner guard (the preparation's, the catalog's) saves the canonical word and + restores it. None of these entries runs on the render thread: `submit_unserviced`, the one + control-plane call made inside `process()`, is not guarded and composes nothing. On wasm the + guard is a zero-sized value. `control-plane` gains a `lane` dependency (`Cargo.lock` updated). +- **Preparation entry (coordinator addition).** host-core's + `prepare_host_runtime_with_live_controls_policy_and_spectrum` (`crates/host-core/src/prepare.rs`) + holds one guard for its whole body, so `GraphCompiler::compile_with_builtins` -> `compile_graph` + (route and send gains, `math::db_to_gain_f32`), native effect coefficient preparation and the + successor joins (`join_strips`, `join_routes`) run canonically on every preparation entry; the + inner #1646 guards stay and nest. Gate: the #1646 fixture of + `canonical_preparation::a_hostile_caller_environment_prepares_the_canonical_faders` now gives + every route its own non-0 dB gain (`-0.77 - 0.53 k`) and asserts at least one converts + differently under the hostile word; red, checked once, with only this entry guard removed + (plane 0 differs). +- **Live level paths covered.** Every live level record is lowered by `classify_live_edit` / + `classify_live_staged` (`live_delta.rs` `Family::*`), which only `SessionState::commit_live` + calls, inside `SessionState::command`'s guard: fader and VCA (`level_records`, + `EffectiveDiff` / `LocalDiff`), pan/matrix (`matrix_record`, `lower_matrix_or_pan`), sends and + routes (`route_records` -> `route_record_between` -> `route_record` -> `route_coefficients`, + which converts the gain on the control thread) and input filters (`input_records`). A + successor's carried retargets (`join_strips`, `join_routes`, `input_records` at + `prepare.rs:2365`) run inside the preparation entry guard. `RouteControlProducer::set` has no + production caller. The gate stays fader + VCA. +- **D2, gate.** A live fader move on a VCA member and a live VCA offset move over it, each sent + while the caller's thread holds FTZ, DAZ and round toward zero, commit live (same plan, no + candidate) and, from the end of their ramp, render the bits of the committed session prepared + on a canonical thread. The pair (member `-0.01384` dB, offset `1/3` dB, #1646's) composes to + another `f32` and another gain under that word (asserted), so the gate is not vacuous. + - C ABI: `runtime::live_tests::set_control::canonical_live::a_live_fader_and_vca_move_under_a_hostile_caller_word_render_the_canonical_bits` + (`crates/capi/src/runtime/live_tests.rs`); it also asserts the catalog's derived + `effective_fader_db` row holds the canonical composition. + - Browser: `tests::canonical_live::a_live_fader_and_vca_move_under_a_hostile_caller_word_render_the_canonical_bits` + (`hosts/host-web/src/tests.rs`, native lib test; the parity rig was not needed). Its reference + is a canonical boot of the committed model on the stateless `flat_fader_base` fixture. + - Both x86-64 and AArch64 cfgs; nothing on wasm. + - Red, checked once, with the guard in `SessionState::command` removed: both tests fail at the + render-bits assertion (C ABI at the fader case; browser at the fader case, and at the VCA case + run alone). With that guard removed the catalog row still passed, because `stage_live` holds + its own guard. +- **Where #1649 and #1650b plug in.** Their live S writes (the offsets-cell write of a VCA move on + an automated member, and a hand move on an automated fader) run inside `SessionState::command`, + so they are already under the guard. Their gate rows join the two `canonical_live` modules: add + a `live_move_under_hostile_word` case per write (an automated member under a VCA for #1649, a + fader hand move over an automated lane for #1650b), reusing the `MEMBER_DB` / `OFFSET_DB` pair. +- **Hostile-word helper (departure, realtime policy).** `realtime-policy` refuses `unsafe` outside + its audited owner files, and the #1646 U1 fold's `stored_fader_automation.rs` already broke it + (two `write_fp_control_word` blocks; the policy failed at the batch head). The one write now + lives in the audited `crates/lane/src/fpenv.rs`: `hostile_fp_control_word` and + `under_hostile_fp_env` (behind lane's `test-support` feature, x86-64 and AArch64), a safe RAII + helper that installs the hostile word over the caller's and restores it on every return, + panics included. The three gate tests call it with no `unsafe` of their own; capi, host-core and + host-web name `lane` with `test-support` as a dev-dependency. The policy's allowlist is + unchanged and passes. All three red checks were run again with the helper. diff --git a/Cargo.lock b/Cargo.lock index 786acdeb7..27ec5499a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,6 +338,7 @@ dependencies = [ "effect-contract", "engine", "host-core", + "lane", "protocol", "session", "source", diff --git a/crates/capi/Cargo.toml b/crates/capi/Cargo.toml index 05c8239ed..94cca7398 100644 --- a/crates/capi/Cargo.toml +++ b/crates/capi/Cargo.toml @@ -28,6 +28,8 @@ builtins = { workspace = true, features = ["test-support"] } # #1309 D8: the control plane's observers and fault hooks, for capi's tests only. A dev-dependency # feature never reaches the release `capi` build (`scripts/check-capi-abi.sh` builds `-p capi`). control-plane = { workspace = true, features = ["test-support"] } +# TBD48: `lane::fpenv::under_hostile_fp_env`, the hostile caller environment of the live gate. +lane = { workspace = true, features = ["test-support"] } # B16-R8: `RealtimePlanOwner::active_plan_mut`, so the #1642 gates read the active plan's # timeline. A dev-dependency feature never reaches the release `capi` build. engine = { workspace = true, features = ["test-support"] } diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index c7ceaa236..b583c35b6 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -8068,6 +8068,133 @@ mod set_control { ); assert_eq!(rig.summary().3, 0, "still live"); } + + // TBD48: live level values are composed in the canonical floating-point environment. + + /// TBD48 D2 on the C ABI: a live move made while the host's control thread runs FTZ, DAZ and + /// round toward zero. + #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] + mod canonical_live { + use super::*; + use lane::fpenv::under_hostile_fp_env; + + /// The member dB and the VCA offset of #1646's environment gate: their composition rounds + /// differently under round toward zero, and the two compositions convert to different + /// gains (both asserted below). + const MEMBER_DB: f32 = -0.01384; + const OFFSET_DB: f32 = 1.0 / 3.0; + + /// [`vca_document`] with `eq0`'s left fader at `member` and `inner`'s left offset at + /// `offset` (`outer` stays at `0` dB, so the offsets' sum is `offset` exactly). + fn document(member: f32, offset: f32) -> String { + with_model(&vca_document(), |model| { + model.tracks[0].fader.left_db = member; + model.vcas[0].fader.left_db = offset; + }) + } + + /// The fixture is not vacuous: the hostile word composes the pair to another `f32`, and + /// that `f32` converts to another gain. + fn assert_the_hostile_word_rounds_the_composition() { + let compose = || session::vca_effective_db(MEMBER_DB, [OFFSET_DB, 0.0]); + let canonical = compose(); + let rounded = under_hostile_fp_env(compose); + assert_ne!( + canonical.to_bits(), + rounded.to_bits(), + "the gate is vacuous: the composition rounds alike under the hostile word" + ); + assert_ne!( + builtins::checked_fader_gain(canonical).map(f32::to_bits), + builtins::checked_fader_gain(rounded).map(f32::to_bits), + "the gate is vacuous: the composition's rounding does not reach the gain" + ); + } + + /// One case: a rig on `before` takes `edit` under the hostile word, live, and from the + /// block its ramp leaves behind renders the bits of a canonical preparation of the + /// committed session; the catalog's derived `effective_fader_db` row holds the canonical + /// composition. + fn live_move_under_hostile_word(label: &str, before: &str, edit: SessionEdit) { + let mut rig = Rig::new(before); + for _ in 0..4 { + rig.step(); + } + let (revision, _, epoch, pending) = rig.summary(); + let bytes = rig.transaction(&[edit]); + let (result, response) = under_hostile_fp_env(|| rig.send(&bytes)); + assert_eq!(result, crate::RESULT_OK, "{label}"); + assert_eq!( + edit_path_of(&response), + protocol::EditPath::Live, + "{label}: the move is live" + ); + assert_eq!( + (rig.summary().0, rig.summary().2, rig.summary().3), + (revision + 1, epoch, pending), + "{label}: one revision, the same plan, no candidate" + ); + let committed = rig.snapshot(); + let model = parse_session_json(&committed).expect("model"); + assert_eq!( + ( + model.tracks[0].fader.left_db.to_bits(), + model.vcas[0].fader.left_db.to_bits() + ), + (MEMBER_DB.to_bits(), OFFSET_DB.to_bits()), + "{label}: the committed pair is the fixture's" + ); + let (list, ..) = control_list_all_c(rig.session, &mut rig.request, 256); + let derived = listed(&list, |entry, row| { + entry.address.owner_id.as_deref() == Some("eq0") + && row.name == "effective_fader_db" + && entry.address.lane == Some(protocol::ParameterChannel::Left) + }); + assert_eq!( + control_of(derived).map(|row| row.value.to_bits()), + Some(session::vca_effective_db(MEMBER_DB, [OFFSET_DB, 0.0]).to_bits()), + "{label}: the catalog's derived row holds the canonical composition" + ); + let ramps = model.control_smoothing_samples(); + let k = window(rig.latency(), ramps.fader.max(64), rig.quantum); + let edit_block = rig.block; + let live: Vec> = (0..k + 4).map(|_| rig.step()).collect(); + let reference = Reference::run(&committed, rig.block); + for (offset, pcm) in live.iter().enumerate().skip(k as usize) { + let block = edit_block as usize + offset; + assert!(pcm.iter().any(|sample| *sample != 0.0), "{label}: signal"); + assert_eq!( + bits(pcm), + bits(&reference[block]), + "{label}: block {block} renders the canonical preparation's bits" + ); + } + } + + /// TBD48 D2 (C ABI): a live fader move on a VCA member, and a live VCA offset move over + /// it, each made while the caller's thread runs FTZ, DAZ and round toward zero, render + /// from the end of their ramp the bits of the same committed session prepared on a + /// canonical thread, and the catalog's derived fader row holds the canonical composition. + /// The pair's composition rounds to another `f32`, and another gain, under that word + /// (asserted), so the gate is not vacuous. + /// + /// Test value: red if a live transaction composes a member's effective fader (the VCA + /// sum) in the caller's environment: remove the guard in `SessionState::command`. + #[test] + fn a_live_fader_and_vca_move_under_a_hostile_caller_word_render_the_canonical_bits() { + assert_the_hostile_word_rounds_the_composition(); + live_move_under_hostile_word( + "a live fader move", + &document(0.0, OFFSET_DB), + set(builtin("eq0", FADER_DB, Lane::Left), MEMBER_DB, None), + ); + live_move_under_hostile_word( + "a live VCA move", + &document(MEMBER_DB, 0.0), + set(vca_row("inner", VCA_OFFSET_DB, Lane::Left), OFFSET_DB, None), + ); + } + } } // --------------------------------------------------------------------------------------------- diff --git a/crates/control-plane/Cargo.toml b/crates/control-plane/Cargo.toml index fff033932..07fa7f532 100644 --- a/crates/control-plane/Cargo.toml +++ b/crates/control-plane/Cargo.toml @@ -20,6 +20,8 @@ controls.workspace = true engine.workspace = true effect-contract.workspace = true host-core = { workspace = true, features = ["control-provider"] } +# TBD48 D1: every control entry composes level values in `lane::CanonicalFpEnv`. +lane.workspace = true protocol.workspace = true session.workspace = true source.workspace = true diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index 4e4f56b60..1d7ab08cb 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -1301,6 +1301,10 @@ pub fn compile_children( where A::Row: Send + 'static, { + // TBD48 D1: the boot preparation, the catalog and every effective value composed here run in + // the canonical floating-point environment, whatever the host's control thread has set. The + // guard restores the caller's word on every return. + let _canonical = lane::CanonicalFpEnv::enter(); // The C ABI needs the transactional `SessionStore` for the control protocol, so it parses and // caps through the facade and builds the store itself; the facade never sees the protocol. let model = parse_host_session(document).map_err(prepare_failure)?; diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 17949a9f4..d741a818a 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -1951,7 +1951,17 @@ where /// Runs one control command frame. A success returns the response's length in the response /// scratch ([`Self::command_response`]); a structural command either rides the newest plan's /// live lanes or prepares, admits and publishes a replacement plan. + /// + /// **Floating-point environment (TBD48 D1).** The whole command runs in + /// `lane::CanonicalFpEnv`: the live classification composes each strip's effective fader + /// (`controls::effective`, the VCA sum), the catalog stages its derived rows, and a rebuild + /// prepares, all as a fresh preparation of the committed value does, whatever the host's + /// control thread has set. Both hosts reach a transaction through here and nowhere else. The + /// guard restores the caller's word on every return, an error included; a nested guard (the + /// preparation's) saves and restores the canonical word. Render never calls this: it pins its + /// own environment. pub fn command(&mut self, request: &[u8], output_capacity: u64) -> Result { + let _canonical = lane::CanonicalFpEnv::enter(); self.service()?; let output_capacity = usize::try_from(output_capacity).unwrap_or(usize::MAX); // #1613: a transaction of values is prepared in place (no clone, no compile); every other @@ -2159,6 +2169,8 @@ where /// [`Self::declare_discontinuity`] after its service step: the one core path of a declaration, /// which a plain session seek also runs (B16-R9). fn declare_discontinuity_serviced(&mut self) -> Result<(), CommandError> { + // TBD48 D1: the successor's carried level values are composed as `Self::command`'s are. + let _canonical = lane::CanonicalFpEnv::enter(); if !self .newest_providers() .inventory diff --git a/crates/host-core/Cargo.toml b/crates/host-core/Cargo.toml index f883013d0..9df6709e1 100644 --- a/crates/host-core/Cargo.toml +++ b/crates/host-core/Cargo.toml @@ -39,6 +39,8 @@ session.workspace = true source.workspace = true [dev-dependencies] +# #1646 D2 and TBD48: `lane::fpenv::under_hostile_fp_env`, the hostile caller environment. +lane = { workspace = true, features = ["test-support"] } # #1646 gate 2: the program builder's byte report is the charge a fader automation adds. automation.workspace = true bench-support.workspace = true diff --git a/crates/host-core/src/control_provider.rs b/crates/host-core/src/control_provider.rs index 5253bca2d..dd5e3e886 100644 --- a/crates/host-core/src/control_provider.rs +++ b/crates/host-core/src/control_provider.rs @@ -1193,6 +1193,9 @@ impl ControlCatalog { overlay: &MonitorOverlay, sample_rate: u32, ) -> Result { + // TBD48 D1: the derived rows are composed as preparation composes them, in the + // canonical floating-point environment, so a row shows the value render applies. + let _canonical = lane::CanonicalFpEnv::enter(); // The list is walked one owner at a time through one reused buffer, twice: once to size // every table, once to fill them. No pass holds the whole list, so the transient is one // owner's rows, not every control of the session's rows at full size (#1400b). @@ -1503,6 +1506,9 @@ impl ControlCatalog { sample_rate: u32, delta: &crate::LiveDelta<'_>, ) -> Result { + // TBD48 D1: the staged derived rows are composed in the canonical environment, as + // `Self::build` composes them. + let _canonical = lane::CanonicalFpEnv::enter(); #[cfg(any(test, feature = "test-support"))] if STAGE_FAILURES.with(|count| { let pending = count.get(); diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 64063a216..dddba5460 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -2155,6 +2155,12 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( ), PrepareDiagnostics, > { + // TBD48 D1: the whole preparation runs in the canonical floating-point environment, so a + // route or send gain (`compile_graph`'s `db_to_gain_f32`), a native effect's coefficients and + // every value composed below are what a canonical thread prepares, whatever the caller has + // set. The inner guards (builtins body, lowerings, `controls::effective`, the catalog) nest: + // each restores the canonical word this one installed, and this one restores the caller's. + let _canonical = lane::CanonicalFpEnv::enter(); // `None` is the plain entry: no live controls, and a request of defaults for the meter and // observation fields below. Every `Some` attaches live controls. let default_request = HostLiveControlRequest::default(); diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index c769bab4d..05ef1d49d 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -4,9 +4,6 @@ //! `hosts/host-web/src/tests.rs`. Preparation bakes every fader in the canonical floating-point //! environment, whatever the caller's thread has set (D2). -// The D2 environment test writes the thread's floating-point control word, which is `unsafe`. -#![allow(unsafe_code)] - use automation::ProgramBuilder; use builtins_compiler::fader_automation_sizes; use engine::realtime::{PlanUnitEligibility, PlanarBufferMut, RenderIo, RenderTime}; @@ -311,51 +308,14 @@ fn a_left_only_fader_automation_keeps_a_mono_tracks_collapse() { } } -/// The caller words of the D2 environment test: MXCSR on `x86_64`, FPCR on AArch64. -#[cfg(target_arch = "x86_64")] -mod word { - use lane::softfma::{MXCSR_DAZ, MXCSR_FTZ}; - - /// Flush-to-zero, denormals-are-zero and round-toward-zero (RC `11`, bits 13-14). - pub(super) fn hostile(saved: u32) -> u32 { - saved | MXCSR_FTZ | MXCSR_DAZ | 0x6000 - } -} - -/// FPCR `FZ` and `RMode` `11` (round toward zero); AArch64 has no separate DAZ bit. -#[cfg(target_arch = "aarch64")] -mod word { - use lane::fpenv::FPCR_FZ; - - pub(super) fn hostile(saved: u64) -> u64 { - saved | FPCR_FZ | (0b11 << 22) - } -} - #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] mod canonical_preparation { use super::*; - use lane::fpenv::{ - FpControlWord, canonical_fp_control_word, read_fp_control_word, write_fp_control_word, - }; - - /// Writes the saved word back on drop, so a failed assertion leaves the thread as it was. - struct Restore(FpControlWord); - - impl Drop for Restore { - fn drop(&mut self) { - // SAFETY: `self.0` is the word this test read from this thread before it changed it, - // so it sets no reserved or `RES0` bit. - unsafe { write_fp_control_word(self.0) }; - } - } + use lane::fpenv::under_hostile_fp_env; - /// Runs `body` with the thread's control word set to `word`, then restores the saved word. - fn under(word: FpControlWord, body: impl FnOnce() -> T) -> T { - let _restore = Restore(read_fp_control_word()); - // SAFETY: `word` is the canonical word or `word::hostile` of a word read from this thread, - // which sets only FTZ, DAZ and RC (MXCSR) or FZ and RMode (FPCR): no reserved bit. - unsafe { write_fp_control_word(word) }; + /// Runs `body` in the canonical environment (the guard restores the thread's word). + fn canonical(body: impl FnOnce() -> T) -> T { + let _canonical = lane::CanonicalFpEnv::enter(); body() } @@ -368,8 +328,9 @@ mod canonical_preparation { /// Static faders of distinct dB on every lane, input trims and filters, no effect (an effect's /// own preparation is outside this gate), a flat automated lane on track 2 and a moving /// automated pair on track 5. One VCA reaches tracks 1, 2 and 5; track 1's left lane is - /// [`VCA_MEMBER_DB`]. - fn fixture() -> (SessionModel, Vec) { + /// [`VCA_MEMBER_DB`]. Every route carries its own non-0 dB gain (TBD48: `compile_graph` + /// converts it), returned third. + fn fixture() -> (SessionModel, Vec, Vec) { let mut model = bank_model(); model.console.pre_insert.clear(); model.console.post_insert.clear(); @@ -403,7 +364,12 @@ mod canonical_preparation { }, members, }]; - (model, decibels) + let mut route_decibels = Vec::new(); + for (index, route) in model.routes.iter_mut().enumerate() { + route.gain_db = -0.77 - 0.53 * index as f32; + route_decibels.push(route.gain_db); + } + (model, decibels, route_decibels) } /// D2: a caller thread with FTZ, DAZ and round-toward-zero set prepares the plan a canonical @@ -416,14 +382,13 @@ mod canonical_preparation { /// an input coefficient, outside `CanonicalFpEnv` (remove the guard around host-core's /// `controls::effective`, in the builtins preparation body or in /// `into_graph_artifact_with_banks`), so a flat automated lane and a static fader would take - /// the caller's rounding. + /// the caller's rounding; or if a route gain is converted outside it (remove the guard at the + /// entry of host-core's `prepare_host_runtime_with_live_controls_policy_and_spectrum`, TBD48). #[test] fn a_hostile_caller_environment_prepares_the_canonical_faders() { - let canonical = canonical_fp_control_word(); - let hostile = word::hostile(canonical); - let (model, decibels) = fixture(); + let (model, decibels, route_decibels) = fixture(); - let moved = under(hostile, || { + let moved = under_hostile_fp_env(|| { decibels .iter() .filter(|&&db| { @@ -440,19 +405,38 @@ mod canonical_preparation { moved > 0, "the gate is vacuous: no fixture fader converts differently under the hostile word" ); + // TBD48: a route gain the hostile word converts differently, so the guard at the entry + // of host-core's preparation (around `compile_graph`) is discriminated too. + let routes_moved = under_hostile_fp_env(|| { + route_decibels + .iter() + .filter(|&&db| { + let gain = math::db_to_gain_f32(core::hint::black_box(db)); + let pinned = { + let _pin = lane::CanonicalFpEnv::enter(); + math::db_to_gain_f32(core::hint::black_box(db)) + }; + gain.to_bits() != pinned.to_bits() + }) + .count() + }); + assert!( + routes_moved > 0, + "the gate is vacuous: no fixture route gain converts differently under the hostile word" + ); let offset = f64::from(VCA_DB); let composed = session::vca_compose_db(VCA_MEMBER_DB, offset); - let rounded = under(hostile, || session::vca_compose_db(VCA_MEMBER_DB, offset)); + let rounded = under_hostile_fp_env(|| session::vca_compose_db(VCA_MEMBER_DB, offset)); assert_ne!( builtins::checked_fader_gain(composed).map(f32::to_bits), builtins::checked_fader_gain(rounded).map(f32::to_bits), "the gate is vacuous: the VCA composition's rounding does not reach the gain" ); - let prepared_canonical = under(canonical, || prepare(&model, 100_000_000)); - let prepared_hostile = under(hostile, || prepare(&model, 100_000_000)); + let prepared_canonical = canonical(|| prepare(&model, 100_000_000)); + let prepared_hostile = under_hostile_fp_env(|| prepare(&model, 100_000_000)); let render_arm = |prepared: Result| { - under(canonical, || render_prepared(prepared.expect("prepares")).0) + canonical(|| render_prepared(prepared.expect("prepares")).0) }; let reference = render_arm(prepared_canonical); let candidate = render_arm(prepared_hostile); diff --git a/crates/lane/src/fpenv.rs b/crates/lane/src/fpenv.rs index fda7ac756..926a80894 100644 --- a/crates/lane/src/fpenv.rs +++ b/crates/lane/src/fpenv.rs @@ -399,6 +399,54 @@ pub fn canonical_fp_control_word() -> FpControlWord { {} } +/// The hostile caller word of the environment gates (#1646 D2, TBD48 D2): `saved` with +/// flush-to-zero, denormals-are-zero and round toward zero set (MXCSR `FTZ`, `DAZ`, `RC = 11`), or +/// on AArch64 `FPCR.FZ` and `RMode = 11` (AArch64 has no separate DAZ bit). +#[cfg(all(feature = "test-support", target_arch = "x86_64"))] +#[must_use] +pub fn hostile_fp_control_word(saved: FpControlWord) -> FpControlWord { + saved | crate::softfma::MXCSR_FTZ | crate::softfma::MXCSR_DAZ | 0x6000 +} + +/// The hostile caller word of the environment gates: see the `x86_64` item. +#[cfg(all(feature = "test-support", target_arch = "aarch64"))] +#[must_use] +pub fn hostile_fp_control_word(saved: FpControlWord) -> FpControlWord { + saved | FPCR_FZ | (0b11 << 22) +} + +/// Runs `body` with this thread's control word set to [`hostile_fp_control_word`] of the current +/// word, then writes the current word back, also when `body` panics. Test-only: it lets a test +/// stand in for a host whose control thread runs a hostile environment, without `unsafe` of its +/// own (the one write is here, in this audited file). +#[cfg(all( + feature = "test-support", + any(target_arch = "x86_64", target_arch = "aarch64") +))] +pub fn under_hostile_fp_env(body: impl FnOnce() -> T) -> T { + /// Writes the saved word back on drop. + struct Restore(FpControlWord); + + impl Drop for Restore { + fn drop(&mut self) { + scheduling_barrier(); + // SAFETY: `self.0` was read from this thread (the value never leaves this function's + // frame), so it sets no reserved bit. + unsafe { write_fp_control_word(self.0) }; + } + } + + let saved = read_fp_control_word(); + let restore = Restore(saved); + // SAFETY: the hostile word adds only FTZ, DAZ and RC (MXCSR) or FZ and RMode (FPCR), all + // control bits, to a word read from this thread: no reserved or `RES0` bit. + unsafe { write_fp_control_word(hostile_fp_control_word(saved)) }; + scheduling_barrier(); + let value = body(); + drop(restore); + value +} + /// The pinned floating-point environment of one render call. /// /// Construct one at the top of a render entry and let it live for the whole call. On construction diff --git a/hosts/host-web/Cargo.toml b/hosts/host-web/Cargo.toml index aa26fac11..bea0fddef 100644 --- a/hosts/host-web/Cargo.toml +++ b/hosts/host-web/Cargo.toml @@ -42,8 +42,9 @@ math.workspace = true # `control_smoothing_parity.rs`. Never linked into the browser artifact: a dev-dependency does not # reach `-p host-web`'s release build. capi.workspace = true -# Dev-only: `tests/retained_ceilings.rs` picks each budget by the native lane width (#1060). -lane.workspace = true +# Dev-only: `tests/retained_ceilings.rs` picks each budget by the native lane width (#1060), and +# the TBD48 live gate runs a hostile caller environment (`lane::fpenv::under_hostile_fp_env`). +lane = { workspace = true, features = ["test-support"] } # The tests name `protocol` directly: its command frames drive the control plane the C ABI embeds # beside the browser host (`tests/control_parity.rs`, #1560, which absorbed #1399's # `strip_cells_cross_host.rs`). The browser artifact links `protocol` already, through the control diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 17e5418a8..8eed0fda1 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -10158,3 +10158,184 @@ fn a_flat_fader_automation_renders_the_static_fader_bits_in_the_browser() { } } } + +/// TBD48 D2 (browser): a live level move made while the control thread runs FTZ, DAZ and round +/// toward zero. The browser host's own tests run natively, so the caller's word can be set. +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +mod canonical_live { + use super::*; + use lane::fpenv::under_hostile_fp_env; + use protocol::control_core::{ControlAddress, Lane, Owner, Section}; + use protocol::{ + CommandPayload, DecodeScratch, DecodedSuccessResponsePayload, DecodedTypedResponseFrame, + EditPath, ExpectedRevision, ProtocolCodec, RequestId, SessionEdit, SessionRevision, + TypedCommandFrame, + }; + use session::StableId; + + /// The member dB and VCA offset of #1646's environment gate (asserted below to round apart). + const MEMBER_DB: f32 = -0.01384; + const OFFSET_DB: f32 = 1.0 / 3.0; + /// The track the VCA holds. + const MEMBER: usize = 1; + + /// [`flat_fader_base`] with VCA `v` over track 1: the track's left fader at `member`, the + /// VCA's left offset at `offset`. + fn model(member: f32, offset: f32) -> session::SessionModel { + let mut model = flat_fader_base(); + model.vcas = vec![flat_fader_vca(&model, "v", 0.0, &[MEMBER])]; + model.vcas[0].fader.left_db = offset; + model.tracks[MEMBER].fader.left_db = member; + model + } + + fn set(owner: Owner, section: Section, control: u32, value: f32) -> SessionEdit { + SessionEdit::SetControl { + expected_effect: None, + address: ControlAddress { + owner, + section, + control, + lane: Lane::Left, + }, + value, + ramp_samples: None, + } + } + + /// The transaction frame of `edit` at `revision`. + fn frame(edit: &SessionEdit, revision: u64) -> Vec { + let codec = ProtocolCodec::default(); + let mut bytes = vec![0_u8; codec.limits().max_frame_bytes]; + let length = codec + .encode_command_frame_into( + &TypedCommandFrame { + request_id: RequestId::new(7).expect("nonzero request"), + expected_revision: ExpectedRevision::Exact(SessionRevision(revision)), + payload: CommandPayload::SessionTransactionApply(std::slice::from_ref(edit)), + }, + &mut bytes, + ) + .expect("a transaction frame"); + bytes.truncate(length); + bytes + } + + /// The path and revision of an applied transaction's response. + fn applied(response: &[u8]) -> (EditPath, u64) { + let mut fields = [0_u16; 64]; + match ProtocolCodec::default() + .decode_typed_response(response, &mut DecodeScratch::new(&mut fields)) + .expect("a transaction response") + { + DecodedTypedResponseFrame::Success { + header, + payload: DecodedSuccessResponsePayload::SessionTransactionApplied(applied), + } => (applied.edit_path, header.revision.0), + _ => panic!("the transaction applies"), + } + } + + /// A browser host booted on `before` takes `edit` under the hostile word, live, and from the + /// block its ramp leaves behind renders the bits of a canonical boot of the committed model. + fn live_move_under_hostile_word( + label: &str, + before: &session::SessionModel, + edit: &SessionEdit, + ) { + const BLOCKS: u64 = 4; + let document = canonical_session_json(before).expect("canonical"); + let mut host = strip_boot( + &document, + WebBootOptions { + require_sample_rate_hz: 48_000, + require_quantum_frames: STRIP_QUANTUM, + source_ring_frames: STRIP_QUANTUM * 4, + ..WebBootOptions::live_control_defaults() + }, + ); + let mut rendered = Vec::new(); + let mut render = |host: &mut AudioWorkletEngineHost, block: u64| { + submit_strip_source(host, "fixture-source", block, &strip_planes(0, block)); + assert_eq!(host.render_next(), RESULT_OK, "{label}: block {block}"); + rendered.push( + host.output_pcm() + .expect("output") + .iter() + .map(|sample| sample.to_bits()) + .collect::>(), + ); + }; + for block in 0..BLOCKS { + render(&mut host, block); + } + let bytes = frame(edit, before.revision); + let staging = host + .control_request_mut(bytes.len() as u32) + .expect("the control staging"); + staging[..bytes.len()].copy_from_slice(&bytes); + let result = under_hostile_fp_env(|| host.control_command(bytes.len() as u32, None)); + assert_eq!(result, RESULT_OK, "{label}"); + assert_eq!( + applied(host.control_response()), + (EditPath::Live, before.revision + 1), + "{label}: the move is live" + ); + let mut committed = model(MEMBER_DB, OFFSET_DB); + committed.revision = before.revision + 1; + let ramp = u64::from(committed.control_smoothing_samples().fader); + let settled = BLOCKS + ramp.div_ceil(u64::from(STRIP_QUANTUM)) + 1; + for block in BLOCKS..settled + 4 { + render(&mut host, block); + } + let reference = render_flat_fader_web(&committed, settled + 4); + for block in settled..settled + 4 { + let block = block as usize; + assert!( + reference[block] + .iter() + .any(|bits| f32::from_bits(*bits) != 0.0), + "{label}: the reference plays" + ); + assert_eq!( + rendered[block], reference[block], + "{label}: block {block} renders the canonical boot's bits" + ); + } + } + + /// TBD48 D2 (browser): a live fader move on a VCA member, and a live VCA offset move over it, + /// each made through the browser's control entry while the caller's thread runs FTZ, DAZ and + /// round toward zero, render from the end of their ramp the bits of the committed model booted + /// on a canonical thread. The pair's composition rounds to another `f32`, and another gain, + /// under that word (asserted), so the gate is not vacuous. + /// + /// Test value: red if the browser's live transaction composes a member's effective fader in + /// the caller's environment: remove the guard in the core's `SessionState::command`. + #[test] + fn a_live_fader_and_vca_move_under_a_hostile_caller_word_render_the_canonical_bits() { + let compose = || session::vca_effective_db(MEMBER_DB, [OFFSET_DB]); + let rounded = under_hostile_fp_env(compose); + assert_ne!( + builtins::checked_fader_gain(compose()).map(f32::to_bits), + builtins::checked_fader_gain(rounded).map(f32::to_bits), + "the gate is vacuous: the hostile word's composition converts to the same gain" + ); + let member = model(0.0, OFFSET_DB).tracks[MEMBER].id.clone(); + live_move_under_hostile_word( + "a live fader move", + &model(0.0, OFFSET_DB), + &set(Owner::Strip(member), Section::Builtins, 5, MEMBER_DB), + ); + live_move_under_hostile_word( + "a live VCA move", + &model(MEMBER_DB, 0.0), + &set( + Owner::Vca(StableId::parse("v").expect("VCA ID")), + Section::Own, + 1, + OFFSET_DB, + ), + ); + } +} From 66da5d0c1905cbc186ff24f2e772889097643de6 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 19:12:45 +0000 Subject: [PATCH 15/35] Bound automation samples in the published session JSON Schema start_sample and end_sample use a new automation_sample $def (0..=2^53-1), so the schema refuses what the validator refuses since b84450183. Test ties the bound to automation::TIME_LIMIT (U1 fold verification MINOR 2). Co-Authored-By: Claude Opus 5.5 --- .../session/tests/json_contract_artifacts.rs | 46 +++++++++++++++++++ docs/session-v1.schema.json | 6 ++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/session/tests/json_contract_artifacts.rs b/crates/session/tests/json_contract_artifacts.rs index b584c2d36..9baf642ce 100644 --- a/crates/session/tests/json_contract_artifacts.rs +++ b/crates/session/tests/json_contract_artifacts.rs @@ -107,6 +107,52 @@ fn session_schema_u64_pattern_executes_the_exact_unsigned_64_bit_domain() { } } +/// The schema's automation segment sample domain is the validator's: `0..automation::TIME_LIMIT`. +/// Both leaves are probed at the ceiling read from the constant, so a drift in either side turns +/// this red. +#[test] +fn session_schema_automation_sample_domain_is_the_validators_ceiling() { + let source = fs::read_to_string(workspace("docs/session-v1.schema.json")) + .expect("read checked JSON Schema"); + let schema: serde_json::Value = serde_json::from_str(&source).expect("valid JSON Schema JSON"); + let validator = jsonschema::validator_for(&schema).expect("Draft 2020-12 schema compiles"); + let fixture = fs::read_to_string(workspace("fixtures/session/v1/canonical.json")) + .expect("read canonical session artifact"); + let base: serde_json::Value = serde_json::from_str(&fixture).expect("valid session JSON"); + let segment = |leaf: &str, value: String| { + let mut candidate = base.clone(); + candidate["automation"][0]["segments"][0][leaf] = serde_json::Value::String(value); + candidate + }; + + let ceiling = automation::TIME_LIMIT - 1; + for leaf in ["start_sample", "end_sample"] { + for valid in [0, 1, 999_999_999_999_999, ceiling - 1, ceiling] { + assert!( + validator.is_valid(&segment(leaf, valid.to_string())), + "schema refused {leaf} {valid}" + ); + } + for invalid in [ceiling + 1, ceiling + 2, 9_999_999_999_999_999, u64::MAX] { + assert!( + !validator.is_valid(&segment(leaf, invalid.to_string())), + "schema admitted {leaf} {invalid}" + ); + } + for invalid in ["", "01", "-1", "18446744073709551616"] { + assert!( + !validator.is_valid(&segment(leaf, invalid.to_owned())), + "schema admitted {leaf} {invalid:?}" + ); + } + } + // The leaf the schema accepts at the ceiling is the leaf the validator accepts. + let mut model = session::parse_session_json(&fixture).expect("fixture parses"); + model.automation[0].segments[0].start_sample = ceiling - 1; + model.automation[0].segments[0].end_sample = ceiling; + session::canonical_session_json(&model).expect("validator accepts the ceiling"); +} + /// The schema's `revision` domain is the engine validator's (#1503): the ceiling is computed from /// `session::MAX_SESSION_REVISION`, so a drift in either side turns this red. #[test] diff --git a/docs/session-v1.schema.json b/docs/session-v1.schema.json index 6b7c0f59c..5c0c437cd 100644 --- a/docs/session-v1.schema.json +++ b/docs/session-v1.schema.json @@ -35,6 +35,10 @@ "type": "string", "pattern": "^(?:0|[1-9][0-9]{0,17}|[1-8][0-9]{18}|9[0-1][0-9]{17}|92[0-1][0-9]{16}|922[0-2][0-9]{15}|9223[0-2][0-9]{14}|92233[0-6][0-9]{13}|922337[0-1][0-9]{12}|92233720[0-2][0-9]{10}|922337203[0-5][0-9]{9}|9223372036[0-7][0-9]{8}|92233720368[0-4][0-9]{7}|922337203685[0-3][0-9]{6}|9223372036854[0-6][0-9]{5}|92233720368547[0-6][0-9]{4}|922337203685477[0-4][0-9]{3}|9223372036854775[0-7][0-9]{2}|922337203685477580[0-6]|9223372036854775807)$" }, + "automation_sample": { + "type": "string", + "pattern": "^(?:0|[1-9][0-9]{0,14}|[1-8][0-9]{15}|900[0-6][0-9]{12}|90070[0-9]{11}|90071[0-8][0-9]{10}|900719[0-8][0-9]{9}|9007199[0-1][0-9]{8}|90071992[0-4][0-9]{7}|900719925[0-3][0-9]{6}|9007199254[0-6][0-9]{5}|90071992547[0-3][0-9]{4}|9007199254740[0-8][0-9]{2}|90071992547409[0-8][0-9]|9007199254740990|9007199254740991)$" + }, "f32": { "type": "number" }, "channel": { "enum": ["left", "right", "both"] }, "unit": { "enum": ["db", "hz", "milliseconds", "samples", "linear", "ratio"] }, @@ -203,7 +207,7 @@ }, "automation_segment": { "type": "object", "additionalProperties": false, "required": ["shape", "start_sample", "end_sample", "start_value", "end_value", "unit"], - "properties": { "shape": { "enum": ["step", "linear", "exponential"] }, "start_sample": { "$ref": "#/$defs/u64" }, "end_sample": { "$ref": "#/$defs/u64" }, "start_value": { "$ref": "#/$defs/f32" }, "end_value": { "$ref": "#/$defs/f32" }, "unit": { "$ref": "#/$defs/unit" } } + "properties": { "shape": { "enum": ["step", "linear", "exponential"] }, "start_sample": { "$ref": "#/$defs/automation_sample" }, "end_sample": { "$ref": "#/$defs/automation_sample" }, "start_value": { "$ref": "#/$defs/f32" }, "end_value": { "$ref": "#/$defs/f32" }, "unit": { "$ref": "#/$defs/unit" } } }, "automation_offset": { "type": "object", "additionalProperties": false, "required": ["left_db", "right_db"], From 183271903e318c100b90484df899d61673d35081 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 19:25:40 +0000 Subject: [PATCH 16/35] Move an automated fader at the end of a held unmute ramp (#1647c) Gate 8 (the batch 13b held-ramp obligation): a lane that owes a move when a live unmute ramp ends at E is moved there (RetargetGain to the composed target over J, hold_until its completion), including a move due at the next block's entry; the scalar oracle and the fused fader-matrix bank use the same driver. Lane slot 192 -> 208 B (measured, charged). Two one-line accessors outside the paths: CellEvents::into_state, BuiltinFaderBank::is_muted. pcm_digest unchanged. Co-Authored-By: Claude Opus 5.5 --- ...7-render-moving-stored-fader-automation.md | 88 ++++ crates/automation/src/events.rs | 9 + crates/builtins-compiler/src/lib.rs | 393 +++++++++++++++--- crates/builtins/src/lib.rs | 10 + .../tests/stored_fader_automation.rs | 354 +++++++++++++++- 5 files changed, 797 insertions(+), 57 deletions(-) diff --git a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md index 892892354..cc5a5faa1 100644 --- a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md @@ -610,3 +610,91 @@ needs the first iterator dropped; give `FaderCellCursor` a pending second span a `CellEvents` when the first ends (or return the state from `CellEvents`). D1.2's report is `report_held_ramp` at the drain; the adoption re-report belongs with #1648's carry, which must also clear `fresh`. + +## Decision record: #1647c (batch 17) + +The held-ramp obligation (D1.6, batch 13b amendment, gate 8). + +**Shipped.** + +- **The obligation.** `AutomatedFaderCell::obligation` (`HeldRampObligation { owed, due_at_entry }`, + one per automated channel). `owed` is set when the driver pushes a gain event at an offset where + the stage holds the gain (any kind: `Jump`, seek `Set`, a committed or held grid event). +- **`E` (constraint 7).** `FaderEventDriver::new` takes the stage's state of each channel after the + block's drain (`ChannelHold { held, muted, remaining }`, `bank_hold` on the bank, the ramped + fader's accessors on the oracle). Held, not muted, `remaining < frames`: `E = remaining`. A ramp + that ends on the block's last sample sets `due_at_entry`, so `E` is the next block's offset 0. + Muted (settled or ramping to `0.0`): held all block, the obligation kept (constraint 4). Free at + a block entry with no move due: a live write cleared the hold there, so `owed` is discharged + (constraint 4). +- **The cut (constraint 2).** A channel with `E` in the block gets the span `[0, E]` (the seek + step kept in the piece that holds it) and a pending `HoldEnd` with the span `[E + 1, frames)`. + The cursor reports `E` as its next offset once the first piece is spent, so no event past `E` is + pulled before the move. At `E`, any event the cell gives at `E` is consumed; with `owed` it is + replaced (constraint 7) by `RetargetGain` to `checked_fader_gain(vca_compose_db(target(), S))` + over the block's fader jump length `J`, the cell gets `hold_until(completion_of(End, node(E), + J))` (constraint 3), and the second piece's `CellEvents` is built on the same state. Without + `owed` the span is still cut (the events are the same; #1637 events do not depend on span + boundaries) and the event at `E` is pushed as usual. +- **`CellEvents::into_state`** (`crates/automation/src/events.rs`): returns the borrowed state, so + the cursor can report the move's ramp and rebuild its events (the #1647b note's second option). +- **`BuiltinFaderBank::is_muted(lane, channel)`** (`crates/builtins/src/lib.rs`): the bank had + `gain_held` and `ramp_remaining` but nothing to tell a ramp toward unmuted from one toward muted. + Mirrors `FaderMuteRampBuiltins::is_muted`. +- **Scalar oracle.** `render_oracle_fader` passes its ramped fader's state; it runs the same driver. +- No new `debug_assert`: a refused move uses the existing refusal closure and counter + (`refused_events`). The stage's `fader_operations_stopped` stays summed per lane (constraint 6, + #1647b). `finish` still advances a cell through a cut the stage did not reach (no move). + +**Departures.** + +1. **Gate 8 (e) includes one grid retarget.** On a curve that moves after `E`, #1637 commits the + one grid point in the last 64 samples of any ramp it holds the grid for (its own completion + reaches the ramp's end), as after the cell's own `Jump`; constraint 3 asks for exactly that + hold. So `[E, E + J - 1]` cannot equal one `RetargetGain` alone on a moving curve. The test + compares `[E, E + J - 1]` with the stage driven directly with the move at `E` over `J` and that + one grid retarget (`64` samples, curve at its completion); every other grid point in the ramp + is held. On a flat curve ((c)) the comparison is one `RetargetGain`, as written. +2. **Out-of-path edits**, both one accessor: `crates/automation/src/events.rs` + (`CellEvents::into_state`) and `crates/builtins/src/lib.rs` (`BuiltinFaderBank::is_muted`). +3. **The move is made only for an owed channel.** A hold with nothing remembered ends with no + move: the unmute ramp already aimed at the remembered gain, and the fused fader-matrix path + stays available. + +**Gates** (x86-64 AVX2 host). Each gate 8 case runs at quantum 128, 100, 64 and 37 (same bits) +and on the scalar oracle at 128 (same bits). The track is prepared muted; a live unmute of 1,000 +samples is drained at render sample 0, so `E = 1,000` (a block entry at quantum 100 only: the +due-at-entry path). + +- (a), (b): `a_remembered_jump_or_seek_set_moves_the_lane_at_the_unmute_ramps_end` (a step at + timeline 400; a session seek to 6,000 drained with the unmute; `J` = 480 and explicit 0). Exact + composed target from `E + J - 1` (from `E` at 0), the move bit-identical to the stage driven + directly. +- (c): `a_jump_ramping_past_the_unmute_ramps_end_is_moved_to_at_its_end` (a step at 990, ramp to + 1,469; a block entry between the step and `E` at quantum 37 only). +- (d): `a_curve_flat_inside_the_unmute_ramps_last_grid_point_moves_the_lane` (a fade to `-12` dB + ending at timeline 960). +- (e): `grid_events_after_the_unmute_ramps_end_do_not_cut_the_move` (a step at 300 whose ramp ends + at 779, then a fade; a seek to 2,000 on a fade). +- Red on revert, checked once each: no move (all four tests red), no `hold_until` for the move + ((e) red), no `due_at_entry` (all four red, quantum 100), no stage state on the oracle (all four + red at the oracle leg). +- Commands: `cargo test --locked -p automation -p graph -p builtins -p builtins-compiler --features + builtins-compiler/test-support,graph/test-support`; `-p host-core --all-features --test + stored_fader_automation` (17 pass); `-p capi`; `-p host-web --features host-web/test-support`: + pass. fmt; clippy `-D warnings` (automation, builtins, builtins-compiler, host-core; all targets, + all features); no-feature `cargo check --all-targets`: clean. +- `realtime-policy`: 159 regions in 33 files (unchanged). The run fails on the known `unsafe` FP + control-word writes in `crates/host-core/tests/stored_fader_automation.rs` (the U1 fold's test; + fixed in the W lane), the same on the base; with those two lines removed in a local probe it is + `ok`. +- `./target/release/audit capi`: `pcm_digest` `bffc21e857b5e134` (unchanged), 0 violations, 0 + allocations. +- Bytes: `lane_slot_bytes` 192 -> 208 (x86-64: the two obligation flags per channel), measured by + `fader_automation_sizes()` and charged by #1646's sum; no fixture moved. + +**Notes for #1648b.** Carry `HeldRampObligation` with the cell state across a swap, re-report a +carried held ramp with `hold_until` at adoption (`CellState::reposition` drops it) and clear +`fresh` on a carried lane. The successor's first block reads the stage state itself, so `E` needs +no carry; `due_at_entry` must carry with `owed` (a ramp ending on the predecessor's last block). A +release cell takes the same obligation and the same move. diff --git a/crates/automation/src/events.rs b/crates/automation/src/events.rs index d5b882a3f..357773faa 100644 --- a/crates/automation/src/events.rs +++ b/crates/automation/src/events.rs @@ -257,6 +257,15 @@ impl<'a, 's> CellEvents<'a, 's> { } } + /// Ends the block's events early and returns the cell's state, so the caller can report a ramp + /// with [`CellState::hold_until`] and continue the block with a second span (#1647 D1.6: a + /// span cut at a held ramp's end). Consume the events up to the cut first: the state has + /// advanced only through the events already pulled. + #[must_use] + pub fn into_state(self) -> &'s mut CellState { + self.state + } + fn jump_length(&self) -> u32 { match self.program.jump { JumpLength::Block => self.jump, diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index c37447d3d..14d3d78e2 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -657,6 +657,24 @@ struct AutomatedFaderCell { reason = "#1649 reads it as the length of a VCA edit's jump, in the same push (batch R1)" )] offsets_ramp: u32, + /// The held-ramp obligation (#1647 D1.6, batch 13b amendment). + obligation: HeldRampObligation, +} + +/// One automated channel's obligation to move to its cell's target where a held ramp ends with +/// the gain free (#1647 D1.6, batch 13b amendment): while the stage holds the lane's gain (muted, +/// or in a mute or unmute ramp, #1638 D2) a gain event is only remembered, and on a curve flat +/// afterwards the cell emits nothing more, so without the move the lane would stay at the gain +/// the unmute ramp aimed for. +#[derive(Clone, Copy, Debug, Default)] +struct HeldRampObligation { + /// A gain event the stage only remembered is outstanding. Kept through a ramp that ends + /// muted (constraint 4); cleared by the move, or when a live write freed the gain at a block + /// entry (it discharges the obligation there). + owed: bool, + /// The held ramp ends on the last sample of the block just rendered: the move is due at the + /// next block's first sample, which is the first sample the stage reports the gain free. + due_at_entry: bool, } /// One strip lane's stored fader automation (#1646 D1): the plan's program and the lane's @@ -709,6 +727,7 @@ impl LaneFaderAutomation { }), offsets_sum: cell.offsets_sum, offsets_ramp: cell.offsets_ramp, + obligation: HeldRampObligation::default(), } }) }), @@ -755,9 +774,19 @@ impl LaneFaderAutomation { /// block: the mapping extends forward), or `None` past the `i64` range. Checked: a span saturated /// at the `i64` edges (R17-5) never wraps here. fn node_time_at(span: TimelineSpan, offset: u64) -> Option { - let (base, from) = match span.step { - Some(step) if offset >= u64::from(step.offset) => (step.timeline, u64::from(step.offset)), - _ => (span.start, 0), + span_time( + span.start, + span.step.map(|step| (step.offset, step.timeline)), + offset, + ) +} + +/// [`node_time_at`] for a block whose offset 0 is node time `start`, with an optional step +/// `(offset, node time)`. +fn span_time(start: i64, step: Option<(u32, i64)>, offset: u64) -> Option { + let (base, from) = match step { + Some((at, timeline)) if offset >= u64::from(at) => (timeline, u64::from(at)), + _ => (start, 0), }; base.checked_add(i64::try_from(offset - from).ok()?) } @@ -788,6 +817,30 @@ const fn selector_channels(lanes: BuiltinLaneSelector) -> [bool; 2] { /// The most cells one fader owner drives in a block: two channels on each of a bank's lanes. const MAXIMUM_FADER_CELLS: usize = 2 * MAXIMUM_BANK_LANES; +/// One automated channel's stage state at a block's first sample, after the live drain (#1647 +/// D1.6): what decides where the stage's hold of its gain ends in the block. +#[derive(Clone, Copy, Debug)] +struct ChannelHold { + /// The stage holds the gain: muted, or in a mute or unmute ramp (#1638 D2). + held: bool, + /// The channel is muted: settled, or in a ramp toward `0.0`. + muted: bool, + /// Frames left in the channel's ramp. + remaining: u32, +} + +/// One channel's block cut at the end `E` of a held ramp (#1647 D1.6, constraint 2): the first +/// piece's span ends at `E`, and the second starts at `E + 1`. +#[derive(Clone, Copy, Debug)] +struct HoldEnd { + /// `E`: the first offset at which the stage reports the gain free (constraint 7). + at: u32, + /// The node time of `E`. + node: i64, + /// The span of `[E + 1, frames)`; `None` when `E` is the block's last sample. + rest: Option, +} + /// One automated channel's events of one block, with the next one pulled (#1647 D1.3). struct FaderCellCursor<'a> { /// The owner's lane (the bank lane; `0` on the scalar oracle). @@ -796,11 +849,76 @@ struct FaderCellCursor<'a> { channel: usize, /// `S`, the offset sum every event's value is composed with (#1646 D1). offsets_sum: f64, - events: automation::CellEvents<'a, 'a>, + /// The cell's program and the block's jump length, to build the second piece. + program: automation::CellProgram<'a>, + jump: u32, + /// The current piece's events; `None` once the block is spent. + events: Option>, + /// The block offset of the current piece's first sample. + base: u32, + /// The next event, its offset in the block. next: Option, + /// The channel's held-ramp obligation (D1.6). + obligation: &'a mut HeldRampObligation, + /// The first offset at which the stage's gain is free in this block: an event pushed below it + /// is only remembered (`0`: free all block; the block length: held all block). + free_from: u32, + /// The cut at the held ramp's end, while it is ahead. + end: Option, } -/// The one event driver of stored fader automation (#1647 D1.3-D1.5, R17-4): both fader owners, +impl FaderCellCursor<'_> { + /// Pull the current piece's next event, rebased to the block. + fn pull(&mut self) { + let base = self.base; + self.next = self + .events + .as_mut() + .and_then(Iterator::next) + .map(|event| automation::Event { + offset: event.offset.saturating_add(base), + ..event + }); + } + + /// The offset of the cursor's next operation: its next event, or the cut at `E` once the + /// first piece is spent (the first piece ends at `E`, so no event lies past it). + fn next_offset(&self) -> Option { + self.next + .map(|event| event.offset) + .or_else(|| self.end.map(|end| end.at)) + } + + /// End the first piece at the cut and start the second, after the move at `E` (if any) has + /// reported its ramp to the cell. Returns the cell's target, for the move. + fn cut(&mut self, end: HoldEnd, moving: bool) -> Option { + self.end = None; + let state = self.events.take()?.into_state(); + let target = state.target(); + if moving { + // Constraint 3: the move holds the grid for its own ramp, as after the cell's Jump. + state.hold_until(automation::completion_of( + automation::Completion::End, + end.node, + self.jump, + )); + } + if let Some(rest) = end.rest { + self.base = end.at.saturating_add(1); + self.events = Some(automation::CellEvents::new( + self.program, + state, + rest, + controls::FADER_CELL_RULE.seek, + self.jump, + )); + self.pull(); + } + target + } +} + +/// The one event driver of stored fader automation (#1647 D1.3-D1.6, R17-4): both fader owners, /// the fader bank and the test-only scalar oracle, render a block's events through it. /// /// It pulls each automated channel's events (#1637, [`automation::SeekMode::Exact`]) and merges @@ -809,8 +927,14 @@ struct FaderCellCursor<'a> { /// Each event's value is composed with the channel's `S` and converted to a fader gain as the /// static path does (`checked_fader_gain(vca_compose_db(value, S))`, #1646 D1); a grid or jump /// event becomes a `RetargetGain` over its ramp, a held event a `RememberGain` and a seek or -/// restart landing a `SetGain` (#1638 D1). Fixed size, on the render stack; allocation-, lock- and -/// syscall-free. +/// restart landing a `SetGain` (#1638 D1). +/// +/// D1.6 (batch 13b amendment): a gain event pushed while the stage holds the channel's gain is +/// only remembered, so the channel owes a move. At the first sample `E` at which the stage +/// reports the gain free, the driver moves the channel to its cell's target over the block's jump +/// length, in place of any event the cell gives at `E`, and reports that ramp to the cell; the +/// channel's span is cut there, so no event past `E` is pulled before the move. Fixed size, on the +/// render stack; allocation-, lock- and syscall-free. struct FaderEventDriver<'a> { cursors: [Option>; MAXIMUM_FADER_CELLS], /// Refused events per owner lane, returned by [`Self::finish`]. @@ -821,7 +945,8 @@ struct FaderEventDriver<'a> { impl<'a> FaderEventDriver<'a> { /// The block of `frames` samples of every automated lane in `lanes`, `(owner lane, lane's - /// automation)`. A lane with no node time this block (no timeline, no jump length, or a span + /// automation)`; `hold(lane, channel)` is the stage's state of that channel after the block's + /// live drain. A lane with no node time this block (no timeline, no jump length, or a span /// saturated at the `i64` edge) holds its value and emits nothing. /// /// D2, departure 4: a lane's first block, with no seek reaching it, lands a `Set` at offset @@ -833,6 +958,7 @@ impl<'a> FaderEventDriver<'a> { lanes: impl Iterator, frames: u32, first_sample: u64, + hold: impl Fn(usize, usize) -> Option, ) -> Self { let _ = first_sample; let mut driver = Self { @@ -868,7 +994,14 @@ impl<'a> FaderEventDriver<'a> { } let program: &'a automation::Program = program; for (channel, cell) in channels.iter_mut().enumerate() { - let Some(cell) = cell.as_mut() else { + let Some(AutomatedFaderCell { + cell, + state, + offsets_sum, + obligation, + .. + }) = cell.as_mut() + else { continue; }; let refused = |driver: &mut Self| { @@ -877,7 +1010,7 @@ impl<'a> FaderEventDriver<'a> { *count = count.saturating_add(1); } }; - let Some(cell_program) = program.cell(cell.cell as usize) else { + let Some(cell_program) = program.cell(*cell as usize) else { refused(&mut driver); continue; }; @@ -886,44 +1019,65 @@ impl<'a> FaderEventDriver<'a> { .map(|step| (step.offset, step.timeline)) .or_else(|| { let value = automation::value_at(cell_program.segments(), span.start); - (first_block - && cell.state.target().map(f32::to_bits) != Some(value.to_bits())) - .then_some((0, span.start)) + (first_block && state.target().map(f32::to_bits) != Some(value.to_bits())) + .then_some((0, span.start)) }); - let (Ok(node), Some(slot)) = ( - automation::NodeSpan::new(span.start, step, frames), + let (free_from, at) = obligation.block(hold(lane, channel), frames); + let end = at.map(|at| hold_end(span.start, step, at, frames)); + let first = match end { + Some(Some(end)) => automation::NodeSpan::new( + span.start, + step.filter(|&(offset, _)| offset <= end.at), + end.at + 1, + ), + _ => automation::NodeSpan::new(span.start, step, frames), + }; + let (Ok(node), Some(slot), false) = ( + first, driver.cursors.get_mut(used), + matches!(end, Some(None)), ) else { refused(&mut driver); continue; }; - let mut events = automation::CellEvents::new( - cell_program, - &mut cell.state, - node, - controls::FADER_CELL_RULE.seek, - jump, - ); - let next = events.next(); - *slot = Some(FaderCellCursor { + let mut cursor = FaderCellCursor { lane, channel, - offsets_sum: cell.offsets_sum, - events, - next, - }); + offsets_sum: *offsets_sum, + program: cell_program, + jump, + events: Some(automation::CellEvents::new( + cell_program, + state, + node, + controls::FADER_CELL_RULE.seek, + jump, + )), + base: 0, + next: None, + obligation, + free_from, + end: end.flatten(), + }; + cursor.pull(); + *slot = Some(cursor); used += 1; } } driver } + /// The fader gain of curve value `value` on a channel whose offset sum is `offsets_sum`, or + /// `None` when the composed value has no fader gain (unreachable: #1645 bounds the curve to + /// the fader domain and `vca_compose_db` clamps, #1646 D6). + fn gain(value: f32, offsets_sum: f64) -> Option { + builtins::checked_fader_gain(session::vca_compose_db(value, offsets_sum)).ok() + } + /// The fader operation of `event` on a channel whose offset sum is `offsets_sum`, or `None` - /// when the composed value has no fader gain (unreachable: #1645 bounds the curve to the - /// fader domain and `vca_compose_db` clamps, #1646 D6). + /// when the composed value has no fader gain. fn operation(event: automation::Event, offsets_sum: f64) -> Option { - let db = session::vca_compose_db(event.value, offsets_sum); - let gain = builtins::checked_fader_gain(db).ok()?; + let gain = Self::gain(event.value, offsets_sum)?; Some(if event.held { builtins::FaderOperation::RememberGain { gain } } else { @@ -941,23 +1095,79 @@ impl<'a> FaderEventDriver<'a> { /// Consume every event the stage did not pull (none, unless a stage stopped pulling: the /// cell's state must still advance through its block) and return the refused events per - /// owner lane. + /// owner lane. A cut the stage did not reach starts its second piece with no move. fn finish(mut self) -> [u64; MAXIMUM_BANK_LANES] { for cursor in self.cursors.iter_mut().flatten() { while cursor.next.is_some() { - cursor.next = cursor.events.next(); + cursor.pull(); + } + if let Some(end) = cursor.end { + let _ = cursor.cut(end, false); + while cursor.next.is_some() { + cursor.pull(); + } } } self.refused } } +impl HeldRampObligation { + /// The block's hold of one channel from its stage state `hold` after the live drain: the + /// first offset at which the gain is free, and `E` when the hold ends with the gain free in + /// this block (a ramp that ends exactly at the block's end makes `E` the next block's first + /// sample). A channel free at a block entry with no move due had its hold cleared by a live + /// write there, which discharges the obligation (constraint 4). + fn block(&mut self, hold: Option, frames: u32) -> (u32, Option) { + let due = core::mem::take(&mut self.due_at_entry); + match hold { + Some(ChannelHold { + held: true, + muted: false, + remaining, + }) if remaining > 0 => { + if remaining < frames { + (remaining, Some(remaining)) + } else { + self.due_at_entry = remaining == frames; + (frames, None) + } + } + Some(ChannelHold { held: true, .. }) => (frames, None), + Some(_) if due => (0, Some(0)), + Some(_) => { + self.owed = false; + (0, None) + } + None => (0, None), + } + } +} + +/// The cut of a block of `frames` samples whose offset 0 is node time `start`, with seek step +/// `step`, at offset `at`; `None` past the `i64` range (unreachable: the caller checked the +/// block's span). +fn hold_end(start: i64, step: Option<(u32, i64)>, at: u32, frames: u32) -> Option { + let node = span_time(start, step, u64::from(at))?; + let next = at + 1; + let rest = if next < frames { + let rest_start = span_time(start, step, u64::from(next))?; + let rest_step = step + .filter(|&(offset, _)| offset > at) + .map(|(offset, timeline)| (offset - next, timeline)); + Some(automation::NodeSpan::new(rest_start, rest_step, frames - next).ok()?) + } else { + None + }; + Some(HoldEnd { at, node, rest }) +} + impl builtins::FaderOperationSource for FaderEventDriver<'_> { fn next_offset(&mut self) -> Option { self.cursors .iter() .flatten() - .filter_map(|cursor| cursor.next.map(|event| event.offset)) + .filter_map(FaderCellCursor::next_offset) .min() } @@ -968,33 +1178,75 @@ impl builtins::FaderOperationSource for FaderEventDriver<'_> { cursors, refused, .. } = self; for cursor in cursors.iter_mut().flatten() { - let Some(event) = cursor.next.filter(|event| event.offset == offset) else { + let event = cursor.next.filter(|event| event.offset == offset); + let end = cursor.end.filter(|end| end.at == offset); + if event.is_none() && end.is_none() { continue; - }; + } let selector = if cursor.channel == 0 { BuiltinLaneSelector::Left } else { BuiltinLaneSelector::Right }; - let pushed = Self::operation(event, cursor.offsets_sum) - .is_some_and(|operation| sink.push(cursor.lane, selector, operation).is_ok()); - if !pushed { + let lane = cursor.lane; + let mut refuse = || { debug_assert!(false, "a stored fader event is refused at render"); - if let Some(count) = refused.get_mut(cursor.lane) { + if let Some(count) = refused.get_mut(lane) { *count = count.saturating_add(1); } + }; + // Constraint 7: the move at `E` replaces any gain operation the cell gives at `E`. + let moving = end.is_some() && cursor.obligation.owed; + if let Some(event) = event { + if !moving { + let pushed = + Self::operation(event, cursor.offsets_sum).is_some_and(|operation| { + sink.push(cursor.lane, selector, operation).is_ok() + }); + if !pushed { + refuse(); + } + // The stage only remembers a gain while it holds the lane. + if offset < cursor.free_from { + cursor.obligation.owed = true; + } + #[cfg(any(test, feature = "test-support"))] + test_only_record_fader_event(TestOnlyFaderEvent { + lane: cursor.lane, + channel: cursor.channel, + sample: first_sample.saturating_add(u64::from(offset)), + kind: event.kind, + held: event.held, + value: event.value, + ramp: event.ramp, + }); + } + cursor.pull(); + } + let Some(end) = end else { + continue; + }; + let target = cursor.cut(end, moving); + if moving { + cursor.obligation.owed = false; + let jump = cursor.jump; + let pushed = target + .and_then(|target| Self::gain(target, cursor.offsets_sum)) + .is_some_and(|gain| { + sink.push( + cursor.lane, + selector, + builtins::FaderOperation::RetargetGain { + gain, + samples: jump, + }, + ) + .is_ok() + }); + if !pushed { + refuse(); + } } - #[cfg(any(test, feature = "test-support"))] - test_only_record_fader_event(TestOnlyFaderEvent { - lane: cursor.lane, - channel: cursor.channel, - sample: first_sample.saturating_add(u64::from(offset)), - kind: event.kind, - held: event.held, - value: event.value, - ramp: event.ramp, - }); - cursor.next = cursor.events.next(); } } } @@ -2464,8 +2716,12 @@ impl GraphPreparedBuiltinBankProcessor for FaderBankProcessor { bank.process(left, right, frames); } else { // #1647 D1.3-D1.5: the automated lanes' events, through the stage's timed path. - let mut driver = - FaderEventDriver::new(automated_lanes(automation), frames, first_sample); + let mut driver = FaderEventDriver::new( + automated_lanes(automation), + frames, + first_sample, + bank_hold(bank), + ); let report = bank.process_with(left, right, frames, &mut driver); let refused = driver.finish(); settle_fader_events(automation, refused, report); @@ -2506,6 +2762,18 @@ fn automated_lanes( .filter_map(|(lane, slot)| slot.as_mut().map(|slot| (lane, slot))) } +/// The stage state of a fader bank's lane and channel after the block's live drain, for +/// [`FaderEventDriver::new`] (#1647 D1.6). +fn bank_hold(bank: &BuiltinFaderBank) -> impl Fn(usize, usize) -> Option + '_ { + |lane, channel| { + Some(ChannelHold { + held: bank.gain_held(lane, channel)?, + muted: bank.is_muted(lane, channel)?, + remaining: bank.ramp_remaining(lane, channel)?, + }) + } +} + /// Add a block's refused events and the stage's stop to the automated lanes' counters. fn settle_fader_events( automation: &mut [Option], @@ -3247,8 +3515,14 @@ impl GraphPreparedBuiltinBankProcessor for FaderMatrixBankProcessor { // #1647 D1.5: with stored automation the pair fuses only when no fader lane has an event // in the block (the matrix half declines a ramping matrix itself); its fallback renders // the events through the stage's timed path. - let mut driver = (!automation.is_empty()) - .then(|| FaderEventDriver::new(automated_lanes(automation), frames, first_sample)); + let mut driver = (!automation.is_empty()).then(|| { + FaderEventDriver::new( + automated_lanes(automation), + frames, + first_sample, + bank_hold(bank), + ) + }); let fused = match driver.as_mut() { None => bank.try_process_settled_with_matrix(&mut matrix.bank, left, right, frames), Some(driver) => bank.try_process_settled_with_matrix_and_operations( @@ -6584,6 +6858,13 @@ fn render_oracle_fader( core::iter::once((0, &mut *automation)), frames, first_sample, + |_, channel| { + Some(ChannelHold { + held: fader.gain_held(channel)?, + muted: fader.is_muted(channel), + remaining: fader.ramp_remaining(channel)?, + }) + }, ); let report = fader.process_with(block, &mut driver); let [refused, ..] = driver.finish(); diff --git a/crates/builtins/src/lib.rs b/crates/builtins/src/lib.rs index be7b4300c..7a47da803 100644 --- a/crates/builtins/src/lib.rs +++ b/crates/builtins/src/lib.rs @@ -4618,6 +4618,16 @@ impl BuiltinFaderBank { Some(per_width!(FaderStageKernel(stage) in &self.stage => stage.gain_held(lane, channel))) } + /// Whether a member lane is muted on `channel` (`0` left, `1` right): settled muted or in a + /// ramp toward `0.0`. `None` for a padding lane or a channel above `1`. + #[must_use] + pub fn is_muted(&self, lane: usize, channel: usize) -> Option { + if lane >= self.members || channel > 1 { + return None; + } + Some(per_width!(FaderStageKernel(stage) in &self.stage => stage.is_muted(lane, channel))) + } + /// Frames left in a member lane's ramp on `channel`; `0` when settled. `None` for a padding /// lane or a channel above `1`. #[must_use] diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index 8d4d196f6..2e50a9016 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -5,7 +5,7 @@ //! its `a(n)` and, per block, its node-time mapping and the fader row's jump length, with no //! render allocation. The events on the fader (#1647b): completion samples, jumps, latency, the //! session seek, quantum invariance, allocation and the scalar oracle's parity (#1647 gates 1-6, -//! R17-4). +//! R17-4). The held-ramp obligation (#1647c, gate 8). //! Preparation bakes every fader in the canonical floating-point environment, whatever the //! caller's thread has set (D2). @@ -1297,6 +1297,358 @@ fn a_lane_a_successor_adds_starts_at_its_curve() { } } +// #1647c: the held-ramp obligation (D1.6, batch 13b amendment, gate 8). + +/// The render sample at which gate 8's unmute ramp ends: `E`, the first sample the stage reports +/// the gain free. A live unmute of `UNMUTE` samples is drained at render sample 0, a block entry at +/// every quantum, and `E` is a block entry at quantum 100 only (the move is then due at a block's +/// first sample). +const UNMUTE: u32 = 1_000; + +/// Gate 8's quanta. +const QUANTA: [u32; 4] = [128, 100, 64, 37]; + +/// One track prepared muted on both lanes, with `segments` on its fader and fader length +/// `fader_ms`. +fn muted_ride(segments: Vec, fader_ms: f32) -> (SessionModel, Automation) { + let mut model = plain(1, 128); + model.control_smoothing = smoothing(fader_ms); + ( + model.tracks[0].fader.left_mute, + model.tracks[0].fader.right_mute, + ) = (true, true); + let entry = fader_entry(&model, 0, ParameterChannel::Both, segments); + model.automation = vec![entry.clone()]; + (model, entry) +} + +/// The output of `model` at `quantum` over `frames`: a live unmute ramp of [`UNMUTE`] samples on +/// track 0 is drained at render sample 0, after an optional session seek to timeline `seek`. +fn unmuted_at(model: &SessionModel, quantum: u32, frames: u64, seek: Option) -> [Vec; 2] { + let mut model = model.clone(); + model.quantum_frames = quantum; + unmuted(Rig::live(&model), frames, seek) +} + +/// [`unmuted_at`] on the test-only scalar oracle (R17-4) at the model's quantum. +#[cfg(feature = "test-support")] +fn unmuted_on_the_oracle(model: &SessionModel, frames: u64, seek: Option) -> [Vec; 2] { + let document = canonical_session_json(model).expect("canonical"); + let caps = rig_caps(model); + let compiled = host_core::compile_host_session(&document, &caps) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + let (mut prepared, handles) = host_core::test_only_prepare_host_runtime_with_live_controls_on( + &compiled, + &caps, + &host_core::HostLiveControlRequest::default(), + graph_compiler::Backend::Scalar, + ) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + let gate = prepared.plan.take_revision_gate(); + let mut rig = Rig::from(prepared, model); + rig.handles = Some(handles); + rig.gate = gate; + unmuted(rig, frames, seek) +} + +/// Run `rig` (with live controls and its plan's gate) as [`unmuted_at`] describes. +fn unmuted(mut rig: Rig, frames: u64, seek: Option) -> [Vec; 2] { + use builtins_compiler::TestOnlyWriteGate; + if let Some(timeline) = seek { + rig.seek(timeline); + } + let handles = rig.handles.as_mut().expect("live controls"); + rig.gate + .as_mut() + .expect("the plan's gate") + .write_published(|stamp| { + handles.strip_controls[0].fader.write( + stamp, + builtins_compiler::TrackFaderRecord::Mute { + lanes: builtins::BuiltinLaneSelector::Both, + muted: false, + smoothing_samples: UNMUTE, + }, + ); + }); + rig.until(frames); + rig.out.map(|mut plane| { + plane.truncate(frames as usize); + plane + }) +} + +/// Gate 8's run of `model`: the same bits at every quantum of [`QUANTA`] and on the scalar oracle +/// (returned at the first quantum), and the unmute ramp's end `E` at the gain it aimed for, the +/// prepared value (`entry`'s curve at node time 0: no latency here). +fn gate_eight( + model: &SessionModel, + entry: &Automation, + frames: u64, + seek: Option, +) -> [Vec; 2] { + let first = unmuted_at(model, QUANTA[0], frames, seek); + for &quantum in &QUANTA[1..] { + let other = unmuted_at(model, quantum, frames, seek); + for plane in 0..2 { + assert_eq!( + first_difference(&first[plane], &other[plane]), + None, + "quantum {quantum} against {}: plane {plane}", + QUANTA[0] + ); + } + } + #[cfg(feature = "test-support")] + { + let oracle = unmuted_on_the_oracle(model, frames, seek); + for plane in 0..2 { + assert_eq!( + first_difference(&first[plane], &oracle[plane]), + None, + "the scalar oracle: plane {plane}" + ); + } + } + for plane in &first { + assert!(plane[0] < 0.01, "the unmute ramp starts at sample 0"); + assert_eq!( + plane[UNMUTE as usize - 1].to_bits(), + gain(curve(entry, 0)).to_bits(), + "the unmute ramp ends at the pre-unmute gain" + ); + } + first +} + +/// The stage driven directly from gain `start` (its gain at `E - 1`), given `operations` at +/// offsets from `E`, over `frames` samples of DC `1.0`. +fn reference_from( + start: f32, + operations: &[(u64, builtins::FaderOperation)], + frames: u64, +) -> Vec { + let mut stage = builtins::FaderMuteRampBuiltins::new(builtins::BuiltinParameters::default()) + .expect("default parameters"); + stage + .apply_fader_operation( + builtins::BuiltinLaneSelector::Both, + builtins::FaderOperation::SetGain { gain: start }, + ) + .expect("start gain"); + let mut out = Vec::with_capacity(frames as usize); + let mut next = operations.iter().peekable(); + let mut at = 0_u64; + while at < frames { + while let Some(&&(sample, operation)) = next.peek() + && sample == at + { + stage + .apply_fader_operation(builtins::BuiltinLaneSelector::Both, operation) + .expect("operation"); + next.next(); + } + let end = next + .peek() + .map_or(frames, |&&(sample, _)| sample.min(frames)); + let mut left = vec![1.0_f32; (end - at) as usize]; + let mut right = left.clone(); + stage.process(builtins::DualMonoBlock::new(&mut left, &mut right, at).expect("block")); + out.extend_from_slice(&left); + at = end; + } + out +} + +/// Assert that `out[E..E + J]` equals the stage driven directly from `out[E - 1]` with +/// `operations` (offsets from `E`). +fn assert_move( + out: &[Vec; 2], + jump: u32, + operations: &[(u64, builtins::FaderOperation)], + label: &str, +) { + let e = UNMUTE as usize; + let span = u64::from(jump.max(1)); + for plane in out { + let expected = reference_from(plane[e - 1], operations, span); + assert_eq!( + first_difference(&plane[e..e + span as usize], &expected), + None, + "{label}: samples [E, E + J - 1]" + ); + } +} + +/// The move at `E`: one `RetargetGain` to `db` over `jump`. +fn retarget(db: f32, jump: u32) -> (u64, builtins::FaderOperation) { + ( + 0, + builtins::FaderOperation::RetargetGain { + gain: gain(db), + samples: jump, + }, + ) +} + +/// Assert that the lane equals `gain(db)` exactly from `E + J - 1` (from `E` with `J = 0`) to +/// the end of `out`, and not before: it settles at the cell's target one fader jump length after +/// `E`, never at the pre-unmute gain. +fn assert_settles(out: &[Vec; 2], jump: u32, db: f32, label: &str) { + let settled = UNMUTE as usize + jump.max(1) as usize - 1; + for plane in out { + assert!( + plane[settled..] + .iter() + .all(|value| value.to_bits() == gain(db).to_bits()), + "{label}: the lane equals the composed target from E + J - 1" + ); + if jump > 1 { + assert_ne!(plane[settled - 1].to_bits(), gain(db).to_bits(), "{label}"); + } + } +} + +/// #1647 gate 8 (a) and (b): a `Jump` (a step at timeline 400) and a seek `Set` (a session seek +/// to timeline 6,000 drained with the unmute) inside an unmute ramp on a curve flat after it. +/// The stage only remembers each; at `E` the lane moves to the gain composed from the cell's +/// target, so it equals that gain exactly from `E + J - 1` (from `E` with `control_smoothing` +/// explicit 0), the same bits at quantum 128, 100, 64 and 37. +/// +/// Test value: red if a remembered `Jump` or seek `Set` is never moved to at `E` (the lane +/// settles at the pre-unmute gain), if the move follows a block entry instead of `E`, or if the +/// move's length is not the fader jump length. +#[test] +fn a_remembered_jump_or_seek_set_moves_the_lane_at_the_unmute_ramps_end() { + const FRAMES: u64 = 2_048; + for fader_ms in [10.0, 0.0] { + let (model, entry) = muted_ride(steps(&[(400, -6.0)]), fader_ms); + let jump = controls::LiveRamps::for_session(&model).fader_samples; + let out = gate_eight(&model, &entry, FRAMES, None); + let label = format!("(a) {fader_ms} ms"); + assert_move(&out, jump, &[retarget(-6.0, jump)], &label); + assert_settles(&out, jump, -6.0, &label); + + let (model, entry) = muted_ride(steps(&[(5_000, -9.0)]), fader_ms); + assert_eq!(curve(&entry, 6_000).to_bits(), (-9.0_f32).to_bits()); + let out = gate_eight(&model, &entry, FRAMES, Some(6_000)); + let label = format!("(b) {fader_ms} ms"); + assert_move(&out, jump, &[retarget(-9.0, jump)], &label); + assert_settles(&out, jump, -9.0, &label); + } +} + +/// #1647 gate 8 (c): a `Jump` at timeline 990 whose own 480-sample ramp ends after `E`, with a +/// block entry between the `Jump` and `E` at quantum 37 only. Samples `[E, E + J - 1]` equal the +/// stage driven directly with one `RetargetGain` at `E` over `J`: no grid event cuts the move's +/// ramp, and the bits are the same at every quantum. +/// +/// Test value: red if the move does not take the cell's target, if it is built at a block entry, +/// or if a grid event cuts its ramp. +#[test] +fn a_jump_ramping_past_the_unmute_ramps_end_is_moved_to_at_its_end() { + const FRAMES: u64 = 2_048; + let (model, entry) = muted_ride(steps(&[(990, -6.0)]), 10.0); + let jump = controls::LiveRamps::for_session(&model).fader_samples; + assert_eq!(jump, 480); + let out = gate_eight(&model, &entry, FRAMES, None); + assert_move(&out, jump, &[retarget(-6.0, jump)], "(c)"); + assert_settles(&out, jump, -6.0, "(c)"); +} + +/// #1647 gate 8 (d): a curve that goes flat inside the last 64 samples of the unmute ramp (a +/// linear fade from `0` to `-12` dB that ends at timeline 960), with no `Jump` or `Set`: only grid +/// events. The last grid point in the ramp commits `-12` dB, which the stage only remembers; at +/// `E` the lane moves there and settles from `E + J - 1`. +/// +/// Test value: red if a remembered grid event is never moved to at `E` (the lane stays at the +/// pre-unmute gain on the flat curve), or if the move is cut by a grid event. +#[test] +fn a_curve_flat_inside_the_unmute_ramps_last_grid_point_moves_the_lane() { + const FRAMES: u64 = 2_048; + for fader_ms in [10.0, 0.0] { + let (model, entry) = muted_ride( + vec![segment(AutomationShape::Linear, 0, 960, 0.0, -12.0)], + fader_ms, + ); + let jump = controls::LiveRamps::for_session(&model).fader_samples; + let out = gate_eight(&model, &entry, FRAMES, None); + let label = format!("(d) {fader_ms} ms"); + assert_move(&out, jump, &[retarget(-12.0, jump)], &label); + assert_settles(&out, jump, -12.0, &label); + } +} + +/// The grid operations of the move's ramp at `E` (node time `node`) over `jump` on a curve with no +/// discontinuity after it: the move to the cell's target `target` dB, then the one grid point in +/// the ramp's last 64 samples, which #1637 commits as after the cell's own `Jump` (every earlier +/// grid point is held by the move's report, batch 13b constraint 3). +fn move_operations( + entry: &Automation, + node: i64, + target: f32, + jump: u32, +) -> Vec<(u64, builtins::FaderOperation)> { + let last = (node + i64::from(jump) - 1).div_euclid(64) * 64; + vec![ + retarget(target, jump), + ( + (last - node) as u64, + builtins::FaderOperation::RetargetGain { + gain: gain(curve(entry, last + 63)), + samples: 64, + }, + ), + ] +} + +/// #1647 gate 8 (e): grid events that are not held lie between `E` and the next block entry: a +/// `Jump` whose ramp ends before `E` on a curve that moves after it (the grid point at timeline +/// 1,024 lies before the next entry at quantum 100 and 37 only), and a seek `Set` (a session seek +/// to timeline 2,000 drained with the unmute) on a moving curve. Samples `[E, E + J - 1]` equal the +/// stage driven directly with one `RetargetGain` at `E` over `J` and the one grid point #1637 +/// commits in that ramp's last 64 samples (departure 1 of #1647c): no other grid event cuts the +/// move's ramp, at any quantum. +/// +/// Test value: red if the move does not hold the grid for its own ramp (the grid events after +/// `E` cut it into 64-sample retargets), or if it follows a block entry instead of `E`. +#[test] +fn grid_events_after_the_unmute_ramps_end_do_not_cut_the_move() { + const FRAMES: u64 = 2_048; + let jump = 480; + // A step to -6 dB at timeline 300 (its ramp ends at 779), then a fade to -30 dB. + let (model, entry) = muted_ride( + vec![ + segment(AutomationShape::Step, 0, 300, 0.0, -6.0), + segment(AutomationShape::Linear, 300, 5_000, -6.0, -30.0), + ], + 10.0, + ); + let out = gate_eight(&model, &entry, FRAMES, None); + // The cell's target at `E`: the last grid point before it (960) committed the curve at 1,023. + let target = curve(&entry, 960 + 63); + assert_move( + &out, + jump, + &move_operations(&entry, i64::from(UNMUTE), target, jump), + "(e) jump", + ); + + let (model, entry) = muted_ride( + vec![segment(AutomationShape::Linear, 0, 10_000, -30.0, 0.0)], + 10.0, + ); + let out = gate_eight(&model, &entry, FRAMES, Some(2_000)); + let node = 2_000 + i64::from(UNMUTE); + let target = curve(&entry, (node - 1).div_euclid(64) * 64 + 63); + assert_move( + &out, + jump, + &move_operations(&entry, node, target, jump), + "(e) seek", + ); +} + /// The caller words of the D2 environment test: MXCSR on `x86_64`, FPCR on AArch64. #[cfg(target_arch = "x86_64")] mod word { From 5e82ba245bf6a982c8b5e9ccaba7fef3d0708842 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 19:43:56 +0000 Subject: [PATCH 17/35] Compose VCA offsets with stored fader automation (#1649) A live VCA move over an automated fader lane writes the lane's offsets cell (S as f64 plus the fader-row jump length) instead of a FaderDb record; S has one author (controls::effective) and both level_records paths compare its bits. The fader bank (and the fused fader-matrix bank and the scalar oracle) reads the cell at the drain and jumps to the composed gain. Parity on both hosts, allocation-free gate, TBD48 hostile-word case. Lane slot 192 -> 224 B, FaderCells 136 -> 144 B, 80 B writer box per automated controlled strip (all measured, charged). Realtime policy 159 -> 160. pcm_digest unchanged. Co-Authored-By: Claude Opus 5.5 --- ...ca-offsets-with-stored-fader-automation.md | 114 +++++ crates/builtins-compiler/src/lib.rs | 436 ++++++++++++++++-- crates/capi/src/runtime/live_tests.rs | 293 +++++++++++- crates/control-plane/src/control.rs | 35 +- .../tests/vca_automation_realtime.rs | 276 +++++++++++ crates/host-core/src/lib.rs | 2 +- crates/host-core/src/live_delta.rs | 42 +- crates/host-core/src/prepare.rs | 89 +++- crates/host-core/tests/live_delta.rs | 220 ++++++++- .../tests/stored_fader_automation.rs | 43 +- hosts/host-web/src/tests.rs | 54 ++- hosts/host-web/tests/control_parity.rs | 274 +++++++++++ 12 files changed, 1780 insertions(+), 98 deletions(-) create mode 100644 crates/control-plane/tests/vca_automation_realtime.rs diff --git a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md index d3e5cfb57..bcdbb762a 100644 --- a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md @@ -299,3 +299,117 @@ other options. - **R17-6.** `S = offset + Σ reaching VCA offsets` is the fader's hand offset only. The trim and send offsets live in #1658 and #1654, which keep their own cells. This issue's `S` does not change and its offsets cell is the fader's. + +## Decision record: #1649 (batch 17) + +Shipped on `codex/controls-batch-17w` over `39fbb8997`, uncommitted, for the coordinator. + +**Classifier (D4, departures 1-3).** `S` is read from `controls::EffectiveStrip::offsets_sum` +(#1646b, the one author); `controls` has no edit. `host-core/src/prepare.rs` gains +`LevelRecords { fader, offsets }` and `level_records_between`, the one rule both `LevelDiff` forms +call (`EffectiveDiff::strip_level_records`, full path; `LocalDiff::level_records`, fast path): the +`fader_records` of #1648 D3, plus, per lane that the post-change model automates +(`controls::automated_fader_lanes`, asked at most once) whose `S` differs in bits, one +`FaderOffsetsRecord { offsets_sum: next S, smoothing_samples: LevelRules::lengths(..).fader[lane] }` +(departure 1: one length rule, so automated and plain members of one edit ramp alike). +`level_differs` also compares `S` bits, so a VCA edit that leaves the effective fader's bits (a +clamp, a zero's sign) still reaches an automated lane. `EffectiveDiff::level_records` (the +successor join's form) keeps returning the fader records only: no carry change (departure 5). +`LiveStripRecords` gains `offsets: [Option; 2]` (departure 4, the strip-level +family); a strip with only offsets writes is pushed, so the path is `live`. + +**Commit (D4, D7).** `commit_live_once` step 2b refuses, before the stamp and any write, a strip +whose offsets write names a channel with no offsets cell in the newest epoch (`INTERNAL`, never +acked then dropped); after the stamp each record is written whole (`FaderCells::write_offsets`, one +`LatestCell<3>` slot: `S` low word, high word, ramp word). A cell write cannot fail. + +**Cell plumbing.** The offsets cell (`Arc`) is now created at preparation in +`prepare_fader_automation` and held by `PreparedFaderCell` (no longer `Copy`), so the strip's +`FaderCells` (writer, `attach_offsets`, a box of `[Option; 2]` only for strips with +fader automation and a control lane) and the fader owner (`LaneFaderAutomation::new`, both bank +lowering and the scalar oracle) share the same cell. `resource_plan` charges the writer box per +automated controlled strip (`Layout::new::<[Option; 2]>()`, 80 bytes on x86-64); +the cell itself stays #1646's bank charge. + +**Render (D3).** `AutomatedFaderCell` gains the cell's `CellReader` and `DirtyReader`. +`LaneFaderAutomation::read_offsets` runs at block entry, after the fader/mute drain and the held +ramp report, in `FaderBankProcessor::drain` (so the fused fader-matrix bank, which drains through +it, too) and in the oracle's `LiveControlFaderProcessor::drain_controls`, through one +`FaderJumpStage` trait (bank lane / `FaderMuteRampBuiltins`). A dirty cell is read with +`read_counted` (replaced writes count as `live_values_superseded`). `AutomatedFaderCell::offsets_jump`: +target value `v` = the curve at the jump's completion (`completion_of(End, node time of sample 0, +ramp)`, never past the next discontinuity, as `CellEvents` aims a ramp); gain +`checked_fader_gain(vca_compose_db(v, S_new))`; compared in bits with the lane's current target +`checked_fader_gain(vca_compose_db(cell target, S_old))`; equal: nothing. Otherwise the stage takes +`RetargetGain` over the ramp word (`SetGain` for 0) at the block's first sample, the cell commits +`v` (`seed_target`) and holds until the completion, so grid events inside the jump are held. + +**Departures.** + +1. **A held lane forgets its target.** When the stage's gain is held (muted, or in a mute/unmute + ramp) a gain operation only remembers the gain (#1638 D2), so after an unmute ramp the stage + would not retarget to it unless the cell emits again. The jump still sends the operation (the + stage remembers it) and the cell is reset to a fresh `CellState` (no target, whose next free + grid event always retargets), re-reporting the stage's remaining held ramp. No new + `automation` API. +2. **No timeline** (a plan without one, not reachable from host-core): the jump's value is the + cell's own target. +3. **A lane's first block** (#1647 D2 `fresh`): if a jump lands in a lane's very first block on a + moving curve, the restart `Set` of that block overrides it (an exact set with the new `S`). +4. **Paths outside the list (mechanical / test):** `crates/host-core/src/lib.rs` (re-export + `FaderOffsetsRecord`); `crates/host-core/tests/stored_fader_automation.rs` (the R17-4 oracle + parity test now also writes offsets cells, one under a held unmute ramp); `hosts/host-web/src/tests.rs` + (TBD48 `canonical_live`, as TBD48's record asks); `control.rs` test snapshot gains + `strip_offsets_cells` and `FaderCells::test_only_offsets_write_counts` (test-support). +5. **Superseded test.** #1648a's `an_automated_fader_lane_takes_no_fader_record` is replaced by + gate 5's test, which keeps its D3 assertions (no `FaderDb` on an automated lane, the other + lane's record, the mute record, fast path equals full path). +6. **Gate 2's nested case** renders no differently (`1e-30` against `2e-30` composes to the same + gain bits), so its `S`-bit claim is asserted in the classifier gate against + `controls::effective` of the edited session; the browser leg asserts `live`, parity and the + fresh boot's bits. + +**Gates (all pass, x86-64 AVX2).** + +- Gates 1, 3 (C ABI): `set_control::automated_vca::a_vca_move_over_an_automated_member_is_live_and_renders_the_prepared_plan` + (session length and 256 samples; eq0 one offsets write per lane, eq1 `[1, 1, 0, 0]` fader cell + writes = one `Both` `FaderDb`; unchanged value `model_only`, no write, no bit; a VCA not reaching + eq0 writes no eq0 cell; every render block 0 allocations). **Red on revert** (the bank's + `read_fader_offsets` call removed): fails at block 18 against the prepared plan, checked once. +- Gate 4 (C ABI): `set_control::automated_vca::vca_reach_and_count_changes_over_an_automated_member_are_live` + (member added to a second VCA, nested parent, new VCA, first VCA, VCA removed). +- Gates 1, 3 (browser): `a_vca_move_over_an_automated_member_is_live_alike_on_both_hosts`; gate 2: + `many_vca_moves_before_one_render_are_the_last_alike_on_both_hosts`; gate 4: + `vca_reach_and_count_changes_over_an_automated_member_are_live_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`, four rates, parity with the C ABI and a fresh boot of + the committed session). +- Gate 5: `a_vca_move_writes_the_automated_lanes_offsets_cell` (`crates/host-core/tests/live_delta.rs`; + also the nested three-VCA `S` bits: `1e-30`, not `0.0`). +- Gate 6: `a_vca_move_over_an_automated_member_renders_allocation_free` + (`crates/control-plane/tests/vca_automation_realtime.rs`, four scripted moves incl. two in one + block, each asserted `live`; 0 allocations and frees per render call after warm-up). +- Oracle (R17-4): `the_scalar_oracle_renders_the_banks_fader_events` with offsets writes; red with + the oracle's `read_offsets` removed (plane 0 at sample 1920), checked once. +- TBD48: both `canonical_live` modules gain "a live VCA move on an automated lane". +- Commands: `cargo test --locked -p controls -p control-plane` (and `--features test-support`), + `-p host-core --all-features`, `-p builtins-compiler --features test-support`, `-p capi`, + `-p host-web --features host-web/test-support --lib --test control_parity`: pass. + `realtime-policy`: ok, 160 regions in 33 files (159 + the jump region). fmt; clippy `-D warnings` + (touched crates, all targets, all features); no-feature `cargo check --all-targets`: clean. +- `./target/release/audit capi`: `pcm_digest` `bffc21e857b5e134` (unchanged), 0 violations, 0 + allocations. + +**Bytes (x86-64).** `lane_slot_bytes` 192 -> 224 (the offsets reader and dirty reader), measured by +`fader_automation_sizes()`; `size_of::()` 136 -> 144 (the writer box pointer), so every +live-controlled strip's `TrackControlProducer` grows by 8 bytes, charged by its vector layout; an +automated controlled strip adds the 80-byte writer box. No fixture moved. + +**`expect(dead_code)`.** None left on `OffsetsLane` or `AutomatedFaderCell::{offsets, offsets_ramp}`. + +**For #1648b.** The carry must move `AutomatedFaderCell`'s whole offsets state with the lane (the +successor's cell is its own, seeded with its `S`; do not carry the predecessor's reader). The +adoption comparison is the same composed-gain rule as `offsets_jump` (current target composed with +the predecessor's heard `S`, against the successor's curve composed with its seeded `S`); reuse +`offsets_jump`'s shape. **For #1650b.** A hand move on an automated fader changes `S` through +`EffectiveStrip::offsets_sum`; `level_records_between` then writes the offsets cell with no other +change (the `Fader` row length via `LevelRules::lengths`). diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index c37447d3d..74d2e2451 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -299,9 +299,95 @@ impl MatrixLane { pub struct FaderCells { lane: Arc, writers: [CellWriter; FADER_CELLS], + /// The offsets cells' write ends of the strip's automated fader channels (#1649 D4), + /// `[left, right]`; `None` (no allocation) on a strip with no stored fader automation. + offsets: Option; 2]>>, +} + +/// Control's write end of one automated fader channel's offsets cell (#1649 D4). +struct OffsetsWriter { + lane: Arc, + writer: CellWriter, +} + +/// One automated fader channel's offsets-cell value (#1649 D4): the lane's offset sum `S` +/// (`controls::EffectiveStrip::offsets_sum`) and the length of the jump a change of it asks for +/// (the lane's fader length of the edit, `LevelRules::lengths`). A write stores both in one slot, +/// so render never reads the halves of two sums. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FaderOffsetsRecord { + /// `S`. + pub offsets_sum: f64, + /// The jump's length in samples; `0` is an exact set. + pub smoothing_samples: u32, +} + +impl FaderOffsetsRecord { + /// The cell's words: `S`'s `f64` bits, low word first, then the ramp word. + const fn words(self) -> [u32; OFFSETS_CELL_WORDS] { + let bits = self.offsets_sum.to_bits(); + [bits as u32, (bits >> 32) as u32, self.smoothing_samples] + } + + /// The record a slot's words hold. + const fn from_words(words: [u32; OFFSETS_CELL_WORDS]) -> Self { + Self { + offsets_sum: f64::from_bits((words[0] as u64) | ((words[1] as u64) << 32)), + smoothing_samples: words[2], + } + } } impl FaderCells { + /// Whether the strip's fader channel `channel` (`0` left, `1` right) has an offsets cell: its + /// fader is automated in the plan these cells belong to (#1649). A host checks every + /// [`Self::write_offsets`] of a transaction with it before its first write. + #[must_use] + pub fn has_offsets(&self, channel: usize) -> bool { + self.offsets + .as_deref() + .and_then(|writers| writers.get(channel)) + .is_some_and(Option::is_some) + } + + /// Write `record` as the newest value of channel `channel`'s offsets cell, the whole slot at + /// once (#1312 D1, #1649 D4), stamped with the revision of the transaction that commits it. A + /// write cannot fail; a channel with no offsets cell ([`Self::has_offsets`] is `false`) is a + /// caller fault the caller checked before its first write, and writes nothing. + pub fn write_offsets( + &mut self, + stamp: LiveStamp<'_>, + channel: usize, + record: FaderOffsetsRecord, + ) { + let writer = self + .offsets + .as_deref_mut() + .and_then(|writers| writers.get_mut(channel)) + .and_then(Option::as_mut); + debug_assert!( + writer.is_some(), + "an offsets write to a channel with no cell" + ); + if let Some(OffsetsWriter { lane, writer }) = writer { + writer.write(&lane.cell, &lane.dirty, 0, stamp, record.words()); + } + } + + /// Give these cells the write ends of the strip's offsets cells (#1649 D4), `[left, right]`; + /// nothing when no channel is automated. Control side: it allocates one box. + fn attach_offsets(&mut self, cells: &[Option; 2]) { + if cells.iter().all(Option::is_none) { + return; + } + self.offsets = Some(Box::new(core::array::from_fn(|channel| { + cells[channel].as_ref().map(|cell| OffsetsWriter { + lane: Arc::clone(&cell.offsets), + writer: CellWriter::new(), + }) + }))); + } + /// Write `record` as the newest value of the cell(s) it addresses, stamped with the revision /// of the transaction that commits it. A `Both` record writes the left and the right cell. /// @@ -351,6 +437,21 @@ impl FaderCells { .map_or(0, |(_, sequence)| sequence) }) } + + /// Test-only: how many writes each offsets cell has taken (#1649), `[left, right]`; `None` + /// for a channel with no cell. Valid while no write is in flight. + #[doc(hidden)] + #[must_use] + pub fn test_only_offsets_write_counts(&self) -> [Option; 2] { + core::array::from_fn(|channel| { + let writer = self.offsets.as_deref()?.get(channel)?.as_ref()?; + Some( + CellReader::new() + .peek_unread(&writer.lane.cell) + .map_or(0, |(_, sequence)| sequence), + ) + }) + } } /// Control's write end of one strip's matrix/pan cell (#1312 D5). @@ -582,6 +683,7 @@ fn fader_lane(superseded: &LiveValuesSuperseded) -> (FaderCells, FaderReader) { FaderCells { lane: Arc::clone(&lane), writers: core::array::from_fn(|_| CellWriter::new()), + offsets: None, }, FaderReader { lane, @@ -616,21 +718,32 @@ fn matrix_lane(superseded: &LiveValuesSuperseded) -> (MatrixCells, MatrixReader) /// edit asks for). const OFFSETS_CELL_WORDS: usize = 3; -/// What one automated fader channel's offsets cell shares between its writer (control, #1649) and -/// its reader (render, #1647): one #1312 latest-target cell and its dirty word. +/// What one automated fader channel's offsets cell shares between its writer (control, +/// [`FaderCells::write_offsets`]) and its reader (render, [`LaneFaderAutomation::read_offsets`]): +/// one #1312 latest-target cell and its dirty word (#1649). /// -/// The cell is created unwritten; its reader holds the prepared words until its first applied -/// write (#1646 departure 5, as #1639's jump-length cell does). -#[expect( - dead_code, - reason = "#1649 writes the cell and adds its render read (until then render composes with \ - the held `S`, #1647b), in the same push (batch R1)" -)] +/// The cell is created unwritten at preparation; its reader holds the prepared words until its +/// first applied write (#1646 departure 5, as #1639's jump-length cell does). struct OffsetsLane { cell: LatestCell, dirty: DirtyWord, } +impl OffsetsLane { + fn new() -> Self { + Self { + cell: LatestCell::new(), + dirty: DirtyWord::new(), + } + } +} + +impl core::fmt::Debug for OffsetsLane { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("OffsetsLane").finish_non_exhaustive() + } +} + /// One automated fader channel as a fader owner holds it (#1646 D1): its cell in the plan's /// program, its event state and its offsets cell with the words render holds until the first /// write. @@ -642,20 +755,17 @@ struct AutomatedFaderCell { cell: u32, /// Seeded with the prepared curve value, so the first grid event on a flat curve emits nothing. state: automation::CellState, - #[expect( - dead_code, - reason = "#1649 writes the cell and adds its render read, in the same push (batch R1)" - )] + /// The offsets cell (#1649), shared with the strip's [`FaderCells`] when the strip has a live + /// control lane. offsets: Arc, - /// `S`, held until the offsets cell's first applied write; every event's gain is composed - /// with it (#1646 D1, #1647 D1.4). + /// Render's read end of the offsets cell and its dirty word. + offsets_reader: CellReader, + offsets_dirty: DirtyReader, + /// `S`: the prepared one until the offsets cell's first applied write, then the newest one + /// render read; every event's gain is composed with it (#1646 D1, #1647 D1.4). offsets_sum: f64, - /// The ramp word, held until the offsets cell's first applied write: the session's fader - /// length (`LiveRampRow::VcaOffset`, #1054). - #[expect( - dead_code, - reason = "#1649 reads it as the length of a VCA edit's jump, in the same push (batch R1)" - )] + /// The ramp word of the newest value read: the length of the jump the last change of `S` + /// took (#1649 D3). Prepared as the session's fader length (`LiveRampRow::VcaOffset`, #1054). offsets_ramp: u32, } @@ -691,22 +801,21 @@ struct LaneFaderAutomation { } impl LaneFaderAutomation { - /// The lane's automation, from its prepared cells. Control side: it allocates one offsets - /// cell per automated channel. + /// The lane's automation, from its prepared cells, sharing each channel's offsets cell. + /// Control side. fn new(program: &Arc, cells: &[Option; 2]) -> Self { Self { program: Arc::clone(program), - channels: cells.map(|cell| { - cell.map(|cell| { + channels: core::array::from_fn(|channel| { + cells[channel].as_ref().map(|cell| { let mut state = automation::CellState::new(); state.seed_target(cell.value); AutomatedFaderCell { cell: cell.cell, state, - offsets: Arc::new(OffsetsLane { - cell: LatestCell::new(), - dirty: DirtyWord::new(), - }), + offsets: Arc::clone(&cell.offsets), + offsets_reader: CellReader::new(), + offsets_dirty: DirtyReader::new(), offsets_sum: cell.offsets_sum, offsets_ramp: cell.offsets_ramp, } @@ -747,9 +856,204 @@ impl LaneFaderAutomation { cell.state.hold_until(end); } } + + /// #1649 D3, at block entry under the block's `snapshot`: each automated channel whose + /// offsets cell holds a newer write takes its `S` and ramp word, and the lane jumps to the + /// composed gain at the jump's completion when that gain differs in bits from the lane's + /// current gain target ([`AutomatedFaderCell::offsets_jump`]). An applied read adds the + /// writes it replaced to `superseded`. Allocation-, lock- and syscall-free; one read per + /// automated channel. + fn read_offsets( + &mut self, + snapshot: LiveSnapshot, + superseded: &LiveValuesSuperseded, + stage: &mut impl FaderJumpStage, + ) { + let Self { + program, + channels, + span, + refused_events, + .. + } = self; + for (channel, cell) in channels.iter_mut().enumerate() { + let Some(cell) = cell.as_mut() else { + continue; + }; + if cell.offsets_dirty.pending(&cell.offsets.dirty) == 0 { + continue; + } + let CellRead::Applied { words, .. } = cell.offsets_reader.read_counted( + &cell.offsets.cell, + snapshot, + &mut cell.offsets_dirty, + 0, + superseded, + ) else { + continue; + }; + let record = FaderOffsetsRecord::from_words(words); + if !cell.offsets_jump(program, *span, channel, record, stage) { + debug_assert!(false, "an offsets jump is refused at render"); + *refused_events = refused_events.saturating_add(1); + } + } + } // REALTIME_POLICY_END } +/// The fader stage of one automated lane, as an offsets jump drives it (#1649 D3): a fader bank's +/// lane or the scalar oracle's fader. +trait FaderJumpStage { + /// Whether `channel`'s gain is held (muted, or in a mute or unmute ramp): a gain operation + /// then only remembers the gain (#1638 D2). + fn gain_held(&self, channel: usize) -> bool; + /// Frames left in `channel`'s ramp; `0` when settled. + fn ramp_remaining(&self, channel: usize) -> u32; + /// Apply `operation` to `channel` now, at the block boundary; `false` when the stage refuses. + fn apply(&mut self, channel: usize, operation: builtins::FaderOperation) -> bool; +} + +/// One lane of a fader bank, as a [`FaderJumpStage`]. +struct BankLaneStage<'a> { + bank: &'a mut BuiltinFaderBank, + lane: usize, +} + +// REALTIME_POLICY_BEGIN +impl FaderJumpStage for BankLaneStage<'_> { + fn gain_held(&self, channel: usize) -> bool { + self.bank.gain_held(self.lane, channel) == Some(true) + } + fn ramp_remaining(&self, channel: usize) -> u32 { + self.bank.ramp_remaining(self.lane, channel).unwrap_or(0) + } + fn apply(&mut self, channel: usize, operation: builtins::FaderOperation) -> bool { + self.bank + .apply_fader_operation(self.lane, channel_selector(channel), operation) + .is_ok() + } +} + +#[cfg(any(test, feature = "test-support"))] +impl FaderJumpStage for FaderMuteRampBuiltins { + fn gain_held(&self, channel: usize) -> bool { + Self::gain_held(self, channel) == Some(true) + } + fn ramp_remaining(&self, channel: usize) -> u32 { + Self::ramp_remaining(self, channel).unwrap_or(0) + } + fn apply(&mut self, channel: usize, operation: builtins::FaderOperation) -> bool { + self.apply_fader_operation(channel_selector(channel), operation) + .is_ok() + } +} + +/// The selector of one channel (`0` left, anything else right). +const fn channel_selector(channel: usize) -> BuiltinLaneSelector { + if channel == 0 { + BuiltinLaneSelector::Left + } else { + BuiltinLaneSelector::Right + } +} + +impl AutomatedFaderCell { + /// #1649 D3: take `record` (a new `S` and its ramp word) and jump the lane to it. + /// + /// The jump's target is the curve at the node time of the jump's completion (`End` of a ramp + /// of the ramp word started at the block's first sample, never past the next discontinuity, + /// which jumps of its own), composed with the new `S`: `checked_fader_gain(vca_compose_db(v, + /// S))`, as every event's gain is (#1646 D1). The lane's current gain target is the cell's + /// target composed with the `S` the lane heard before. Equal gain bits take no jump (a `+0.0` + /// `S` that turns a `-0.0` value into `+0.0` moves nothing). Otherwise the lane retargets over + /// the ramp word (an exact set for `0`) at the block's first sample, and the cell commits the + /// target and holds until the completion, so grid events inside the jump are held (README + /// "Held events"). + /// + /// On a lane whose gain is held (muted, or in a mute or unmute ramp) the stage only remembers + /// the gain (#1638 D2), so the target its ramp aims for is no longer one the cell knows: the + /// cell forgets its target (a fresh state, whose next free grid event always retargets) and + /// keeps the held ramp's report. With no node time (a plan with no timeline) the cell's own + /// target is the jump's value. + /// + /// `false` when the composed value has no fader gain, the program has no such cell, or the + /// stage refuses the operation (unreachable: the curve is in the fader domain, + /// `vca_compose_db` clamps, and the lane is a member). + fn offsets_jump( + &mut self, + program: &automation::Program, + span: Option, + channel: usize, + record: FaderOffsetsRecord, + stage: &mut impl FaderJumpStage, + ) -> bool { + let heard = core::mem::replace(&mut self.offsets_sum, record.offsets_sum); + let ramp = record.smoothing_samples; + self.offsets_ramp = ramp; + let completion = controls::FADER_CELL_RULE.completion; + let start = span.and_then(|span| node_time_at(span, 0)); + let value = match (start, program.cell(self.cell as usize)) { + (Some(start), Some(cell)) => { + let mut at = automation::completion_of(completion, start, ramp); + let ahead = cell.discontinuities(); + if let Some(&next) = ahead.get(ahead.partition_point(|&time| time <= start)) + && next <= at + { + at = next - 1; + } + automation::value_at(cell.segments(), at) + } + (Some(_), None) => return false, + (None, _) => match self.state.target() { + Some(value) => value, + None => return true, + }, + }; + let compose = + |value, sum| builtins::checked_fader_gain(session::vca_compose_db(value, sum)).ok(); + let Some(gain) = compose(value, self.offsets_sum) else { + return false; + }; + let current = self + .state + .target() + .and_then(|target| compose(target, heard)); + if current.map(f32::to_bits) == Some(gain.to_bits()) { + return true; + } + let operation = if ramp == 0 { + builtins::FaderOperation::SetGain { gain } + } else { + builtins::FaderOperation::RetargetGain { + gain, + samples: ramp, + } + }; + if stage.gain_held(channel) { + let remaining = stage.ramp_remaining(channel); + let applied = stage.apply(channel, operation); + self.state = automation::CellState::new(); + if let (Some(last), Some(span)) = (remaining.checked_sub(1), span) + && let Some(end) = node_time_at(span, u64::from(last)) + { + self.state.hold_until(end); + } + return applied; + } + if !stage.apply(channel, operation) { + return false; + } + self.state.seed_target(value); + if let Some(start) = start { + self.state + .hold_until(automation::completion_of(completion, start, ramp)); + } + true + } +} +// REALTIME_POLICY_END + // REALTIME_POLICY_BEGIN /// The node time of sample `offset` of a node block mapped by `span` (`offset` may lie past the /// block: the mapping extends forward), or `None` past the `i64` range. Checked: a span saturated @@ -1084,9 +1388,9 @@ pub fn test_only_take_fader_events() -> (Vec, bool) { }) } -/// One automated fader channel as preparation computed it (#1646 D1, D2): plain data, no -/// allocation. -#[derive(Clone, Copy, Debug)] +/// One automated fader channel as preparation computed it (#1646 D1, D2), with its offsets cell +/// (#1649), which the strip's [`FaderCells`] and its fader owner share. +#[derive(Clone, Debug)] struct PreparedFaderCell { /// The cell's index in the program. cell: u32, @@ -1101,6 +1405,8 @@ struct PreparedFaderCell { offsets_sum: f64, /// The offsets cell's ramp word. offsets_ramp: u32, + /// The offsets cell, created unwritten (#1646 departure 5). + offsets: Arc, } /// The stored fader automation of one preparation (#1646): the program every automated lane @@ -1125,7 +1431,11 @@ impl FaderAutomationPlan { let Some(cells) = self.strip(track) else { return effective; }; - [0, 1].map(|lane| cells[lane].map_or(effective[lane], |cell| cell.fader_db)) + [0, 1].map(|lane| { + cells[lane] + .as_ref() + .map_or(effective[lane], |cell| cell.fader_db) + }) } /// The bytes the program retains: its shared allocation and the builder's report. @@ -1244,6 +1554,7 @@ fn prepare_fader_automation( fader_db, offsets_sum, offsets_ramp: ramp, + offsets: Arc::new(OffsetsLane::new()), }); tables.push((entry, segments)); } @@ -2311,6 +2622,7 @@ impl FaderBankProcessor { if held != [0; 2] { report_held_ramps(bank, automation, held); } + read_fader_offsets(bank, controls, automation, snapshot); result } @@ -2522,6 +2834,30 @@ fn settle_fader_events( } } +/// #1649 D3: each automated lane with a live control lane reads its offsets cells under the +/// block's `snapshot` and jumps ([`LaneFaderAutomation::read_offsets`]). A lane with no control +/// lane has no writer, so its cells stay as prepared. +fn read_fader_offsets( + bank: &mut BuiltinFaderBank, + controls: &[Option], + automation: &mut [Option], + snapshot: LiveSnapshot, +) { + for (lane, slot) in automated_lanes(automation) { + let Some(Some(reader)) = controls.get(lane) else { + continue; + }; + slot.read_offsets( + snapshot, + &reader.superseded, + &mut BankLaneStage { + bank: &mut *bank, + lane, + }, + ); + } +} + /// D1.2: report each held ramp the block drain started on an automated lane (bit `lane` of /// `held[channel]`) to that lane's cell, from the stage's remaining count. fn report_held_ramps( @@ -5946,7 +6282,14 @@ fn prepare_session_builtins_with_live_controls_and_policy( None => None, Some(&(input_lane, superseded)) => { let (producer, control) = matrix_lane(superseded); - let (fader_producer, fader_control) = fader_lane(superseded); + let (mut fader_producer, fader_control) = fader_lane(superseded); + // #1649 D4: the strip's offsets cells' write ends, charged by `resource_plan`. + if let Some(cells) = fader_automation + .as_ref() + .and_then(|plan| plan.strip(strip.id.as_str())) + { + fader_producer.attach_offsets(cells); + } let (input_producer, input_control) = if input_lane { let (cells, reader) = input_lane_cells(superseded); (Some(cells), Some(reader)) @@ -6130,11 +6473,32 @@ fn resource_plan( // its seal, and one bounded ring per requested track. add_vector_layout::(&mut processor, controls.len())?; add_vector_layout::<(Box, bool)>(&mut processor, controls.len())?; + // #1649 D4: the strips whose fader automation gives their producer offsets-cell writers (the + // rule `prepare_fader_automation` and `FaderCells::attach_offsets` follow). Transient. + let model = session.normalized_model(); + let automated: BTreeSet<&str> = if model.automation.is_empty() { + BTreeSet::new() + } else { + model + .strips() + .filter(|strip| { + controls::fader_automation(model, strip.id) + .iter() + .any(Option::is_some) + }) + .map(|strip| strip.id.as_str()) + .collect() + }; for control in controls { // #1312 D5, #1346 D6: every lane is a latest-target cell set, one `Arc` allocation each, // charged by the allocation's own layout. The input lane's is charged when requested - // (#1254 D1). + // (#1254 D1). A strip with stored fader automation also holds its offsets cells' write + // ends, one box (#1649 D4); the cells themselves are bank payload (#1646 D4). for (present, layout) in [ + ( + automated.contains(control.track_id.as_str()), + core::alloc::Layout::new::<[Option; 2]>(), + ), ( true, core::alloc::Layout::new::>(), @@ -6865,6 +7229,8 @@ impl LiveControlFaderProcessor { automation.report_held_ramp(channel, remaining); } } + // #1649 D3: the offsets cells, after the drain, as the fader bank reads them. + automation.read_offsets(snapshot, &self.control.superseded, &mut self.fader); } result } diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index b583c35b6..f27612b31 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -8093,6 +8093,31 @@ mod set_control { }) } + /// [`document`] with `eq0`'s left fader also automated, flat at `member` (#1649): its + /// gain is composed at render from the curve and the offsets cell's `S`. + fn automated_document(member: f32, offset: f32) -> String { + with_model(&document(member, offset), |model| { + model.automation.push(session::Automation { + id: id("eq0-flat"), + target: session::ControlAddress { + owner: session::Owner::Strip(id("eq0")), + section: session::Section::Builtins, + control: FADER_DB, + lane: session::ParameterChannel::Left, + }, + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: 0, + end_sample: SOURCE_FRAMES, + start_value: member, + end_value: member, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + }); + }) + } + /// The fixture is not vacuous: the hostile word composes the pair to another `f32`, and /// that `f32` converts to another gain. fn assert_the_hostile_word_rounds_the_composition() { @@ -8171,8 +8196,9 @@ mod set_control { } } - /// TBD48 D2 (C ABI): a live fader move on a VCA member, and a live VCA offset move over - /// it, each made while the caller's thread runs FTZ, DAZ and round toward zero, render + /// TBD48 D2 (C ABI): a live fader move on a VCA member, a live VCA offset move over it, + /// and (#1649) that VCA move over a member whose fader is automated, which writes its + /// offsets cell, each made while the caller's thread runs FTZ, DAZ and round toward zero, render /// from the end of their ramp the bits of the same committed session prepared on a /// canonical thread, and the catalog's derived fader row holds the canonical composition. /// The pair's composition rounds to another `f32`, and another gain, under that word @@ -8193,6 +8219,269 @@ mod set_control { &document(MEMBER_DB, 0.0), set(vca_row("inner", VCA_OFFSET_DB, Lane::Left), OFFSET_DB, None), ); + // #1649: the same VCA move over an automated member writes its offsets cell. + live_move_under_hostile_word( + "a live VCA move on an automated lane", + &automated_document(MEMBER_DB, 0.0), + set(vca_row("inner", VCA_OFFSET_DB, Lane::Left), OFFSET_DB, None), + ); + } + } + + /// #1649: a VCA edit over a member with stored fader automation, on the C ABI. + mod automated_vca { + use super::*; + + /// The fader ride of `eq0` (both lanes): linear from 0 dB at sample 0 to -12 dB at the + /// source's end, so every grid point moves the lane. + fn ride() -> session::Automation { + session::Automation { + id: id("eq0-fader-ride"), + target: session::ControlAddress { + owner: session::Owner::Strip(id("eq0")), + section: session::Section::Builtins, + control: FADER_DB, + lane: session::ParameterChannel::Both, + }, + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: SOURCE_FRAMES, + start_value: 0.0, + end_value: -12.0, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + } + } + + /// The ten-track session at 48 kHz with `eq0` riding its fader, VCA `v` holding `eq0` and + /// `eq1` (which has no automation), and `change` applied. + fn document(change: impl FnOnce(&mut SessionModel)) -> String { + with_model(&long_session(10, 48_000), |model| { + model.automation.push(ride()); + model.vcas.push(vca_of("v", &["eq0", "eq1"])); + change(model); + }) + } + + /// A VCA of `db` on both lanes holding `members`. + fn vca_at(name: &str, db: f32, members: &[&str]) -> session::Vca { + let mut vca = vca_of(name, members); + vca.fader.left_db = db; + vca.fader.right_db = db; + vca + } + + /// `(eq0's offsets-cell writes [left, right], eq1's fader/mute cell writes)` in the current + /// epoch. + fn writes(rig: &Rig) -> ([Option; 2], [u64; 4]) { + let snapshot = crate::ffi::test_transaction_snapshot(rig.session); + let epoch = snapshot.provider_epoch; + let offsets = snapshot + .strip_offsets_cells + .iter() + .find(|(row, strip, _)| *row == epoch && &**strip == "eq0") + .map(|&(_, _, writes)| writes) + .expect("eq0's producer"); + let fader = snapshot + .strip_cells + .iter() + .find(|(row, strip, ..)| *row == epoch && &**strip == "eq1") + .map(|&(_, _, fader, _)| fader) + .expect("eq1's producer"); + (offsets, fader) + } + + /// One case: a rig on `before` plays four blocks and takes `edits` with path `path`, one + /// revision on the same plan with no candidate. From the end of a jump of `jump` samples + /// and the first grid ramp after it (two grid periods of 64), the output equals a plan + /// prepared from the committed session fed the same PCM from frame 0, and no render block + /// allocates or frees. `heard`: the output differs from `before`'s own plan there. Returns + /// the cell writes the edit made, as [`writes`] counts them. + fn case( + label: &str, + before: &str, + edits: &[SessionEdit], + path: protocol::EditPath, + jump: u32, + heard: bool, + ) -> ([Option; 2], [u64; 4]) { + let mut rig = Rig::new(before); + for _ in 0..4 { + rig.step(); + } + let (revision, _, epoch, pending) = rig.summary(); + let (offsets_before, fader_before) = writes(&rig); + let bytes = rig.transaction(edits); + let (result, response) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK, "{label}"); + assert_eq!(edit_path_of(&response), path, "{label}: path"); + assert_eq!( + (rig.summary().0, rig.summary().2, rig.summary().3), + (revision + 1, epoch, pending), + "{label}: one revision, the same plan, no candidate" + ); + let (offsets_after, fader_after) = writes(&rig); + let committed = rig.snapshot(); + let k = window(rig.latency(), jump + 128, rig.quantum); + let edit_block = rig.block; + let mut live = Vec::new(); + for _ in 0..k + 4 { + let (pcm, used) = rig.step_counted(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "{label}: a render block allocated or freed" + ); + live.push(pcm); + } + let reference = Reference::run(&committed, rig.block); + let unedited = Reference::run(before, rig.block); + let mut moved = false; + for (offset, pcm) in live.iter().enumerate().skip(k as usize) { + let block = edit_block as usize + offset; + assert_eq!( + bits(pcm), + bits(&reference[block]), + "{label}: block {block} renders the committed session's plan" + ); + moved |= bits(pcm) != bits(&unedited[block]); + } + assert_eq!( + moved, heard, + "{label}: heard exactly when the edit moves a value" + ); + let delta = |after: Option, before: Option| { + after.zip(before).map(|(after, before)| after - before) + }; + ( + [ + delta(offsets_after[0], offsets_before[0]), + delta(offsets_after[1], offsets_before[1]), + ], + core::array::from_fn(|cell| fader_after[cell] - fader_before[cell]), + ) + } + + /// The session's fader length, in samples: the `VcaOffset` row's length of a VCA edit with + /// no length of its own. + fn fader_length(document: &str) -> u32 { + parse_session_json(document) + .expect("model") + .control_smoothing_samples() + .fader + } + + /// #1649 gates 1 and 3 (C ABI). A -6 dB move of `v` on a playing engine is `live`: the + /// automated member `eq0` takes one offsets-cell write per lane and the plain member `eq1` + /// one `FaderDb` (a `Both` record: one write to each fader cell), and from the end of the + /// jump and the first grid ramp after it the output equals a plan prepared with `v` at + /// -6 dB and the same automation. With the edit's own ramp of 256 samples both members + /// settle within 256 samples (a longer ramp on either would still differ in the window). + /// A move of `v` to its current value, and a move of a VCA that does not reach `eq0`, + /// write no offsets cell and move no bit of `eq0`. + /// + /// Test value: red if a VCA move does not reach the offsets cell (the ride keeps the old + /// composition, so the window differs from the plan), if the automated or the plain + /// member takes another length than the edit's, if the plain member loses its record, or + /// if an unchanged offset still writes the cell or retargets the lane. + #[test] + fn a_vca_move_over_an_automated_member_is_live_and_renders_the_prepared_plan() { + bench_support::alloc::assert_installed(); + let before = document(|_| {}); + let fader = fader_length(&before); + let both = |ramp| vec![set(vca_row("v", VCA_OFFSET_DB, Lane::Both), -6.0, ramp)]; + for (label, ramp, jump) in [ + ("the session's length", None, fader), + ("the edit's own 256 samples", Some(256), 256), + ] { + let (offsets, fader_cells) = case( + label, + &before, + &both(ramp), + protocol::EditPath::Live, + jump, + true, + ); + assert_eq!(offsets, [Some(1), Some(1)], "{label}: eq0's offsets cells"); + assert_eq!(fader_cells, [1, 1, 0, 0], "{label}: eq1's one `FaderDb`"); + } + let (offsets, fader_cells) = case( + "v to its current value", + &before, + &[set(vca_row("v", VCA_OFFSET_DB, Lane::Both), 0.0, None)], + protocol::EditPath::ModelOnly, + fader, + false, + ); + assert_eq!((offsets, fader_cells), ([Some(0), Some(0)], [0; 4])); + let far = document(|model| model.vcas.push(vca_of("far", &["eq2"]))); + let (offsets, _) = case( + "a VCA that does not reach eq0", + &far, + &[set(vca_row("far", VCA_OFFSET_DB, Lane::Both), -6.0, None)], + protocol::EditPath::Live, + fader, + true, + ); + assert_eq!(offsets, [Some(0), Some(0)], "eq0's cells are not written"); + } + + /// #1649 gate 4 (C ABI). Each edit that changes which VCAs reach the automated member, or + /// how many VCAs the session has, is `live` and from the end of the jump renders a plan + /// prepared from the committed session: `eq0` added to a second VCA; that VCA (holding + /// `eq0`) made a member of `v`, so `v` reaches `eq0` through it; a new VCA that reaches + /// `eq0`; the first VCA of a session that had none; a VCA that reaches `eq0` removed. + /// + /// Test value: red if any of these rebuilds, if a lane no VCA reached had no cell to write + /// when a VCA is added, or if a removed VCA leaves its offset in `S`. + #[test] + fn vca_reach_and_count_changes_over_an_automated_member_are_live() { + bench_support::alloc::assert_installed(); + let upsert = |vca: session::Vca| SessionEdit::UpsertVca { vca }; + let base = document(|model| { + model.vcas[0] = vca_at("v", -3.0, &["eq0", "eq1"]); + model.vcas.push(vca_at("w", -4.0, &["eq2"])); + }); + let nested = document(|model| { + model.vcas[0] = vca_at("v", -3.0, &["eq1"]); + model.vcas.push(vca_at("w", -4.0, &["eq0"])); + }); + let bare = document(|model| model.vcas.clear()); + let fader = fader_length(&base); + let cases: [(&str, &str, Vec); 5] = [ + ( + "eq0 added to a second VCA", + &base, + vec![upsert(vca_at("w", -4.0, &["eq0", "eq2"]))], + ), + ( + "the VCA holding eq0 nested in v", + &nested, + vec![upsert(vca_at("v", -3.0, &["eq1", "w"]))], + ), + ( + "a new VCA that reaches eq0", + &base, + vec![upsert(vca_at("x", -2.0, &["eq0"]))], + ), + ( + "the first VCA of the session", + &bare, + vec![upsert(vca_at("v", -6.0, &["eq0", "eq1"]))], + ), + ( + "a VCA that reaches eq0 removed", + &base, + vec![SessionEdit::RemoveVca { vca_id: id("v") }], + ), + ]; + for (label, before, edits) in cases { + let (offsets, _) = + case(label, before, &edits, protocol::EditPath::Live, fader, true); + assert_eq!(offsets, [Some(1), Some(1)], "{label}: eq0's offsets cells"); + } } } } diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index d741a818a..39f9e0272 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -384,6 +384,9 @@ pub struct TestTransactionSnapshot { /// pending candidate: `(epoch, strip ID, fader left, fader right, mute left, mute right, /// matrix)`. A cell's count is its write sequence (#1312 D3). pub strip_cells: Vec<(u64, Box, [u64; 4], u64)>, + /// How many writes every strip producer's offsets cells have taken (#1649), in the order of + /// `strip_cells`: `(epoch, strip ID, [left, right])`, `None` for a channel with no cell. + pub strip_offsets_cells: Vec<(u64, Box, [Option; 2])>, /// How many writes every send producer's cell has taken, current epoch first, then each /// pending candidate: `(epoch, route ID, writes)` (#1557). pub route_cells: Vec<(u64, Box, u64)>, @@ -1554,6 +1557,18 @@ where }) }) .collect(), + strip_offsets_cells: core::iter::once(&self.providers) + .chain(&self.pending_providers) + .flat_map(|provider| { + provider.strips.controls.iter().map(|producer| { + ( + provider.epoch, + producer.track_id.clone(), + producer.fader.test_only_offsets_write_counts(), + ) + }) + }) + .collect(), route_cells: core::iter::once(&self.providers) .chain(&self.pending_providers) .flat_map(|provider| { @@ -3101,8 +3116,20 @@ where // 2b. A strip with input records has an input writer, and every filter target passes the // contract the writer assumes (#1556 D5). Both are checked before the first write: the - // cells' write cannot fail and panics on a target it was never validated for. + // cells' write cannot fail and panics on a target it was never validated for. A strip + // with offsets-cell writes has an offsets cell on each lane it writes (#1649 D4, D7): the + // newest epoch's plan automates the lanes the committed model does, so a missing cell is + // an internal fault, refused before any write, never acknowledged and then dropped. for (strip, &index) in delta.strips.iter().zip(&resolved) { + let fader = &strip_lanes[index].fader; + if strip + .offsets + .iter() + .enumerate() + .any(|(channel, record)| record.is_some() && !fader.has_offsets(channel)) + { + return LiveCommit::Done(Err(CommandError::Internal)); + } if strip.input.is_empty() { continue; } @@ -3244,6 +3271,12 @@ where for record in strip.fader_records() { producer.fader.write(stamp, record); } + // #1649 D4: each automated lane's new `S`, the whole slot at once. + for (channel, record) in strip.offsets.iter().enumerate() { + if let Some(record) = *record { + producer.fader.write_offsets(stamp, channel, record); + } + } if let Some(record) = strip.matrix { producer.producer.write(stamp, record); } diff --git a/crates/control-plane/tests/vca_automation_realtime.rs b/crates/control-plane/tests/vca_automation_realtime.rs new file mode 100644 index 000000000..4669ed236 --- /dev/null +++ b/crates/control-plane/tests/vca_automation_realtime.rs @@ -0,0 +1,276 @@ +//! #1649 gate 6: a VCA edit over a member with stored fader automation is read and rendered +//! allocation-free. +//! +//! The script of gate 1 runs through control-plane's shared commit (`SessionState::command`, the +//! code the browser Worker and the C ABI both run) and host-core's render session +//! (`PlanState::render`): `eq0` rides its fader, VCA `v` holds `eq0` and `eq1` (no automation), +//! and `v` moves live, with the session's length, with an edit length of 256 samples and twice +//! inside one block. Every render call after the warm-up allocates and frees nothing, measured +//! with the audited allocator's thread-scoped counters: the block that reads the offsets cell and +//! jumps, and every block of the jump and the grid after it. + +use control_plane::{ + CompiledChildren, ControlAdapter, ControlLimits, EventLane, PlanResources, PlanState, + RuntimePreparer, SessionState, compile_children, +}; +use host_core::{ + CompiledSession, HostLiveControlHandles, HostLiveControlRequest, HostLiveLanes, + HostPrepareCaps, LiveValuesSuperseded, PrepareDiagnostics, PreparedHost, SourceSubmission, + SuccessorBase, prepare_host_runtime_with_live_lanes, + prepare_host_runtime_with_live_lanes_successor, +}; +use protocol::{ + CommandPayload, DecodeScratch, DecodedSuccessResponsePayload, DecodedTypedResponseFrame, + EditPath, ExpectedRevision, ProtocolCodec, RequestId, SessionEdit, SessionRevision, + TypedCommandFrame, +}; +use session::{ + Automation, AutomationOffset, AutomationSegment, AutomationShape, ControlAddress, + DualMonoFader, Owner, ParameterChannel, ParameterUnit, Section, StableId, Vca, +}; + +const SESSION: &str = include_str!("../../../fixtures/session/v1/parametric-eq-nine-track.json"); +const QUANTUM: usize = 128; +const SOURCE: &[u8] = b"fixture-source"; + +struct Adapter; + +impl ControlAdapter for Adapter { + type Row = PlanResources; + const ADAPTER_ALLOCATIONS: &'static [u64] = &[]; +} + +struct Preparer; + +impl RuntimePreparer for Preparer { + fn prepare( + &self, + compiled: &CompiledSession, + caps: &HostPrepareCaps, + lanes: HostLiveLanes, + successor: Option>, + superseded: &LiveValuesSuperseded, + ) -> Result<(PreparedHost, HostLiveControlHandles), PrepareDiagnostics> { + let live = HostLiveControlRequest::default(); + match successor { + Some(base) => prepare_host_runtime_with_live_lanes_successor( + compiled, caps, &live, lanes, base, superseded, + ), + None => prepare_host_runtime_with_live_lanes(compiled, caps, &live, lanes, superseded), + } + } +} + +fn limits() -> ControlLimits { + ControlLimits { + source_ring_frames: 1_024, + maximum_automation_spans_per_block: 128, + maximum_document_bytes: 1_000_000, + maximum_diagnostic_bytes: 4_096, + maximum_tracks: 100, + maximum_sources: 100, + maximum_routes: 100, + maximum_effects: 100, + maximum_graph_session_plus_plan_bytes: 1_000_000_000, + maximum_source_total_bytes: 100_000_000, + maximum_source_overhead_bytes: 100_000_000, + maximum_effect_state_bytes: 1_000_000_000, + maximum_effect_scratch_bytes: 1_000_000_000, + maximum_builtin_retained_bytes: 1_000_000_000, + maximum_control_retained_bytes: 100_000_000, + maximum_named_allocation_bytes: 1_000_000_000, + maximum_meter_streams: 1, + maximum_meter_items: 1, + maximum_meter_bytes: 1, + maximum_control_frame_bytes: 65_536, + maximum_replay_bytes: 262_144, + maximum_replay_entries: 16, + maximum_submixes: 0, + maximum_vcas: 4, + maximum_vca_reach_pairs: u64::MAX, + maximum_replacement_peak_bytes: u64::MAX, + } +} + +fn id(text: &str) -> StableId { + StableId::parse(text).expect("stable ID") +} + +/// The fixture with `eq0` riding its fader on both lanes (linear, -0 to -12 dB over the source) +/// and VCA `v` holding `eq0` and `eq1`. +fn document() -> (String, u64) { + let mut model = session::parse_session_json(SESSION).expect("fixture"); + let frames = model.sources[0].frames; + model.automation.push(Automation { + id: id("eq0-fader-ride"), + target: ControlAddress { + owner: Owner::Strip(id("eq0")), + section: Section::Builtins, + control: 5, + lane: ParameterChannel::Both, + }, + segments: vec![AutomationSegment { + shape: AutomationShape::Linear, + start_sample: 0, + end_sample: frames, + start_value: 0.0, + end_value: -12.0, + unit: ParameterUnit::Db, + }], + offset: AutomationOffset::default(), + }); + model.vcas.push(Vca { + id: id("v"), + fader: DualMonoFader { + left_db: 0.0, + right_db: 0.0, + left_mute: false, + right_mute: false, + }, + members: vec![id("eq0"), id("eq1")], + }); + ( + session::canonical_session_json(&model).expect("canonical"), + model.revision, + ) +} + +struct Rig { + session: SessionState, + plan: PlanState, + revision: u64, + request: u64, + block: u64, +} + +impl Rig { + /// Feed and render one block; the allocator events of the render call alone. + fn block(&mut self) -> bench_support::alloc::Counters { + let start = self.block * QUANTUM as u64; + let plane: Vec = (0..QUANTUM as u64) + .map(|frame| ((start + frame) % 97) as f32 / 97.0 - 0.5) + .collect(); + self.session + .submit( + SOURCE, + SourceSubmission { + generation: 1, + start_frame: start, + sample_rate_hz: 48_000, + planes: &[&plane, &plane], + frames: QUANTUM as u32, + end_of_region: false, + }, + ) + .expect("source block"); + let mut samples = [0.0_f32; QUANTUM * 2]; + let output = engine::realtime::PlanarBufferMut::try_new(&mut samples, 2, QUANTUM, QUANTUM) + .expect("output planes"); + let mark = bench_support::alloc::current_thread_counters(); + self.plan.render(start, output).expect("a rendered block"); + let used = bench_support::alloc::current_thread_delta_since(mark); + self.block += 1; + used + } + + /// Commit `v`'s offset at `db` on both lanes, over `ramp` samples or the session's length. + fn move_vca(&mut self, db: f32, ramp: Option) { + self.request += 1; + let edits = [SessionEdit::SetControl { + expected_effect: None, + address: ControlAddress { + owner: Owner::Vca(id("v")), + section: Section::Own, + control: 1, + lane: ParameterChannel::Both, + }, + value: db, + ramp_samples: ramp, + }]; + let codec = ProtocolCodec::default(); + let mut bytes = vec![0_u8; codec.limits().max_frame_bytes]; + let length = codec + .encode_command_frame_into( + &TypedCommandFrame { + request_id: RequestId::new(self.request).expect("nonzero request"), + expected_revision: ExpectedRevision::Exact(SessionRevision(self.revision)), + payload: CommandPayload::SessionTransactionApply(&edits), + }, + &mut bytes, + ) + .expect("a transaction frame"); + bytes.truncate(length); + let length = self + .session + .command(&bytes, 65_536) + .expect("the VCA move commits"); + let mut fields = [0_u16; 64]; + let path = match codec + .decode_typed_response( + self.session.command_response(length), + &mut DecodeScratch::new(&mut fields), + ) + .expect("a transaction response") + { + DecodedTypedResponseFrame::Success { + payload: DecodedSuccessResponsePayload::SessionTransactionApplied(applied), + .. + } => applied.edit_path, + DecodedTypedResponseFrame::NonOk { payload, .. } => { + panic!("the VCA move is refused: {payload:?}") + } + DecodedTypedResponseFrame::Success { .. } => panic!("another success payload"), + }; + assert_eq!(path, EditPath::Live, "the VCA move is live"); + self.revision += 1; + // The commit's reliable events, read as a host reads them. + while self + .session + .dequeue_event(EventLane::Reliable, 65_536) + .expect("an event") + .is_some() + {} + } +} + +/// Gate 6. Test value: red if the offsets cell's read, the jump or the composition with the new +/// `S` allocates or frees on render. +#[test] +fn a_vca_move_over_an_automated_member_renders_allocation_free() { + bench_support::alloc::assert_installed(); + let (document, revision) = document(); + let CompiledChildren { session, plan } = + compile_children::(&document, limits(), Box::new(Preparer)).expect("boot compile"); + let mut rig = Rig { + session, + plan, + revision, + request: 1, + block: 0, + }; + // Warm-up: the process statics and the first blocks' one-time work. + for _ in 0..4 { + rig.block(); + } + let script: [&[(f32, Option)]; 4] = [ + &[(-6.0, None)], + &[(-3.0, Some(256))], + // Two moves before one block: render reads the newest slot only. + &[(-9.0, None), (-1.5, Some(256))], + &[(0.0, None)], + ]; + for moves in script { + for &(db, ramp) in moves { + rig.move_vca(db, ramp); + } + for _ in 0..12 { + let block = rig.block; + let used = rig.block(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "block {block} allocated or freed" + ); + } + } +} diff --git a/crates/host-core/src/lib.rs b/crates/host-core/src/lib.rs index 1dded9312..7d1f45b15 100644 --- a/crates/host-core/src/lib.rs +++ b/crates/host-core/src/lib.rs @@ -271,7 +271,7 @@ pub use builtins::BuiltinLaneSelector; /// host that keeps them, as the C ABI does per provider epoch (#1256 D3), names the type without /// depending on `builtins-compiler`. pub use builtins_compiler::{FaderCells, LiveValuesSuperseded, MatrixCells, TrackControlProducer}; -pub use builtins_compiler::{TrackControlRecord, TrackFaderRecord}; +pub use builtins_compiler::{FaderOffsetsRecord, TrackControlRecord, TrackFaderRecord}; pub use effect_contract::{ChannelSymmetryWitness, LiveControlRecord, SeamSide, SymmetryEvent}; #[doc(hidden)] diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index 82c4ea3e7..dfc0f492b 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -25,7 +25,9 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::route_controls::RouteControlRecord; use builtins::{checked_trim_gain, validate_prepared_input_filter_target}; -use builtins_compiler::{TrackControlRecord, TrackFaderRecord, TrackInputRecord}; +use builtins_compiler::{ + FaderOffsetsRecord, TrackControlRecord, TrackFaderRecord, TrackInputRecord, +}; use controls::{ ChangedControl, ControlAddress, ControlClass, EffectiveStrip, Family, LocalInputs, ModelView, Owner, Section, @@ -53,8 +55,8 @@ pub fn take_fast_classification_count() -> usize { // The ramp rule and the per-owner record derivation live with preparation, which needs them on // every build (a successor's carry-then-retarget, #1277 D5); the classifier calls them. use crate::prepare::{ - EffectiveDiff, LevelRules, OutOfDomain, RouteEditRamps, StripChange, StripEditRamps, - effect_instance_records, effective_fader, fader_records, input_records, level_differs, + EffectiveDiff, LevelRecords, LevelRules, OutOfDomain, RouteEditRamps, StripChange, + StripEditRamps, effect_instance_records, input_records, level_differs, level_records_between, matrix_record, route_record_between, route_source_strip, same_params, strip_at, }; use crate::prepare::{GraphJumpLengths, LiveRampRow, LiveRamps, MonitorBase, session_jump_lengths}; @@ -67,6 +69,10 @@ pub struct LiveStripRecords<'a> { /// For the strip's fader/mute queue, in push order: `FaderDb` records first, then `Mute` /// records, each left before right; `None` after the last one. pub fader: [Option; 4], + /// For the offsets cells of the strip's automated fader lanes (#1649 D4), `[left, right]`: + /// the lane's new `S` and the length of its jump, when `S` changes on a lane the post-commit + /// model automates. A host writes each to the lane's cell (`FaderCells::write_offsets`). + pub offsets: [Option; 2], /// For the strip's matrix/pan queue: the new lowered target, when it changes. pub matrix: Option, /// For the strip's input cells (#1556 D2), in write order: the `TrimDb` records, then the @@ -924,6 +930,7 @@ fn lower_strips<'a>( let mut records = LiveStripRecords { strip_id: after.id.as_str(), fader: [None; 4], + offsets: [None; 2], matrix: None, input: Vec::new(), }; @@ -941,13 +948,20 @@ fn lower_strips<'a>( instance: None, }); match lower(family, &ctx, &subject)? { - Lowered::Fader(fader) => records.fader = fader, + Lowered::Fader(level) => { + records.fader = level.fader; + records.offsets = level.offsets; + } Lowered::Matrix(matrix) => records.matrix = matrix, Lowered::Input(input) => records.input = input, Lowered::Effect(_) | Lowered::Route(_) | Lowered::Nothing => {} } } - if records.fader[0].is_some() || records.matrix.is_some() || !records.input.is_empty() { + if records.fader[0].is_some() + || records.offsets.iter().any(Option::is_some) + || records.matrix.is_some() + || !records.input.is_empty() + { delta.strips.push(records); } for section in &item.instances { @@ -1032,8 +1046,8 @@ fn lower_routes<'a>( trait LevelDiff { /// Whether strip `strip`'s effective fader or mute differs, as bits, between the models. fn level_changed(&self, strip: usize) -> bool; - /// The level records of strip `strip`. - fn level_records(&self, strip: usize) -> Result<[Option; 4], OutOfDomain>; + /// The level records of strip `strip`, with its offsets-cell writes (#1649 D4). + fn level_records(&self, strip: usize) -> Result; /// The effective gate of route `route` per source lane, in `[current, next]`, or `None` when /// the form does not hold it. fn gates(&self, route: usize) -> Option<[[bool; 2]; 2]>; @@ -1053,8 +1067,8 @@ impl LevelDiff for EffectiveDiff<'_> { EffectiveDiff::level_changed(self, strip, strip) } - fn level_records(&self, strip: usize) -> Result<[Option; 4], OutOfDomain> { - EffectiveDiff::level_records(self, strip, strip) + fn level_records(&self, strip: usize) -> Result { + EffectiveDiff::strip_level_records(self, strip, strip) } fn gates(&self, route: usize) -> Option<[[bool; 2]; 2]> { @@ -1260,12 +1274,12 @@ impl LevelDiff for LocalDiff<'_> { .is_some_and(|strip| level_differs(&strip.before, &strip.after)) } - fn level_records(&self, strip: usize) -> Result<[Option; 4], OutOfDomain> { + fn level_records(&self, strip: usize) -> Result { let strip = self.strip(strip).ok_or(OutOfDomain)?; let change = self.change(strip); - fader_records( - &effective_fader(&strip.before), - &effective_fader(&strip.after), + level_records_between( + &strip.before, + &strip.after, self.rules.lengths(&change), || controls::automated_fader_lanes(self.rules.next.structure(), change.id), ) @@ -1362,7 +1376,7 @@ struct Target<'s, 'm, 'a> { /// What one lowering produced. enum Lowered<'a> { - Fader([Option; 4]), + Fader(LevelRecords), Matrix(Option), Input(Vec), Effect(Option>), diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index dddba5460..ff2817bce 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -12,9 +12,10 @@ use builtins::{ PreparedInputFilterTarget, checked_fader_gain, checked_trim_gain, validate_input_filter_pair, }; use builtins_compiler::{ - BuiltinCompileCaps, LiveValuesSuperseded, MeterConsumer, MeterRequest, SelectedMeterRequest, - TrackControlProducer, TrackControlRecord, TrackControlRequest, TrackFaderRecord, - TrackInputRecord, lower_matrix_or_pan, prepare_selected_session_builtins_between_render_calls, + BuiltinCompileCaps, FaderOffsetsRecord, LiveValuesSuperseded, MeterConsumer, MeterRequest, + SelectedMeterRequest, TrackControlProducer, TrackControlRecord, TrackControlRequest, + TrackFaderRecord, TrackInputRecord, lower_matrix_or_pan, + prepare_selected_session_builtins_between_render_calls, prepare_selected_session_builtins_with_live_controls, prepare_session_builtins_between_render_calls, prepare_session_builtins_with_live_controls, session_structural_symmetry, @@ -4272,10 +4273,60 @@ pub(crate) fn fader_records( Ok(fader) } -/// A strip's effective fader and mute as a [`session::DualMonoFader`], the shape its stage's -/// records are derived from. -/// Whether a strip's effective fader or mute differs, as bits: the one test of "this strip's level -/// moved", for the dense diff and the local one. +/// The level records of one strip: the [`fader_records`] of its fader/mute stage and the +/// offsets-cell writes of its automated fader lanes (#1649 D4), `[left, right]`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct LevelRecords { + pub(crate) fader: [Option; 4], + pub(crate) offsets: [Option; 2], +} + +/// The level records that bring a strip from `before`'s effective values to `after`'s: its +/// [`fader_records`], and for each lane that `automated` names (the lanes the post-change model +/// automates) whose `S` ([`controls::EffectiveStrip::offsets_sum`]) differs in bits, one +/// offsets-cell write of `after`'s `S` with the lane's fader length (#1649 D4, departure 1: the +/// one length rule, so an automated and a plain member of one VCA edit ramp alike). A VCA ride, a +/// membership change, a nested VCA, an added and a removed VCA each change only `S` on such a +/// lane. `automated` is asked at most once. +/// +/// # Errors +/// +/// [`OutOfDomain`] as [`fader_records`]. +pub(crate) fn level_records_between( + before: &controls::EffectiveStrip, + after: &controls::EffectiveStrip, + lengths: LevelLengths, + automated: impl Fn() -> [bool; 2], +) -> Result { + let lanes = core::cell::OnceCell::new(); + let automated = || *lanes.get_or_init(&automated); + let fader = fader_records( + &effective_fader(before), + &effective_fader(after), + lengths, + automated, + )?; + let mut offsets = [None; 2]; + let moved = + [0, 1].map(|lane| before.offsets_sum[lane].to_bits() != after.offsets_sum[lane].to_bits()); + if moved.contains(&true) { + let automated = automated(); + for lane in 0..2 { + if moved[lane] && automated[lane] { + offsets[lane] = Some(FaderOffsetsRecord { + offsets_sum: after.offsets_sum[lane], + smoothing_samples: lengths.fader[lane], + }); + } + } + } + Ok(LevelRecords { fader, offsets }) +} + +/// Whether a strip's effective fader, mute or offset sum `S` differs, as bits: the one test of +/// "this strip's level moved", for the dense diff and the local one. `S` moves alone when a VCA +/// edit leaves the effective fader's bits (a clamp, a zero's sign), and an automated lane hears +/// `S` (#1649). #[cfg(feature = "control-provider")] pub(crate) fn level_differs( before: &controls::EffectiveStrip, @@ -4283,8 +4334,11 @@ pub(crate) fn level_differs( ) -> bool { before.fader_db.map(f32::to_bits) != after.fader_db.map(f32::to_bits) || before.mute != after.mute + || before.offsets_sum.map(f64::to_bits) != after.offsets_sum.map(f64::to_bits) } +/// A strip's effective fader and mute as a [`session::DualMonoFader`], the shape its stage's +/// records are derived from. pub(crate) fn effective_fader(effective: &controls::EffectiveStrip) -> session::DualMonoFader { session::DualMonoFader { left_db: effective.fader_db[0], @@ -4555,10 +4609,25 @@ impl<'a> EffectiveDiff<'a> { before: usize, after: usize, ) -> Result<[Option; 4], OutOfDomain> { + self.strip_level_records(before, after) + .map(|records| records.fader) + } + + /// The level records of one strip with its offsets-cell writes (#1649 D4): the live path's + /// form of [`Self::level_records`]. + /// + /// # Errors + /// + /// [`OutOfDomain`] as [`fader_records`]. + pub(crate) fn strip_level_records( + &self, + before: usize, + after: usize, + ) -> Result { let change = self.change(before, after); - fader_records( - &effective_fader(&self.before.strips[before]), - &effective_fader(&self.after.strips[after]), + level_records_between( + &self.before.strips[before], + &self.after.strips[after], self.rules.lengths(&change), || controls::automated_fader_lanes(self.rules.next.structure(), change.id), ) diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 46ccf920c..6d957455a 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -1471,44 +1471,214 @@ fn a_fader_automation_edit_is_a_rebuild() { ); } -/// #1648 D3, the hook in the shared level lowering: a lane that `next` automates gets no -/// `FaderDb`, by the full classifier and by the fast path alike; the other lane and the mute keep -/// their records. A VCA move is the case that reaches an automated lane in this slice (the own -/// fader's hand move is #1650's offset write). +/// One strip's level records, owned: its ID, its fader/mute records in push order and its +/// offsets-cell writes `[left, right]` as `(S bits, ramp)` (#1649 D4). +type Level = (String, Vec, [Option<(u64, u32)>; 2]); + +fn level(delta: &LiveDelta<'_>) -> Vec { + delta + .strips + .iter() + .map(|strip| { + ( + strip.strip_id.to_owned(), + strip.fader_records().collect(), + strip.offsets.map(|record| { + record.map(|record| (record.offsets_sum.to_bits(), record.smoothing_samples)) + }), + ) + }) + .collect() +} + +/// `(S bits, ramp)` of an offsets-cell write. +fn sum(offsets_sum: f64, ramp: u32) -> Option<(u64, u32)> { + Some((offsets_sum.to_bits(), ramp)) +} + +/// #1648 D3 and #1649 D4, gate 5, in the shared level lowering: a VCA edit gives a lane that +/// `next` automates no `FaderDb` and, when the lane's `S` changes, one offsets-cell write of the +/// new `S` with the lane's fader length (departure 1: the one length rule), on the full +/// classifier and the fast path alike. Track 0 rides its fader on both lanes and is a member of +/// `vca`; track 1 is a member with no automation, and takes one `FaderDb` per lane as before. /// -/// Test value: red if either classifier path still writes a static `FaderDb` onto an automated -/// lane (a record that fights the curve), if the suppression drops the other lane's record or a -/// mute record, or if a suppressed lane turns into a refusal. +/// Test value: red if a VCA move does not reach an automated lane's offsets cell (an acked ride +/// that is never heard), if a static `FaderDb` still lands on it (a record that fights the curve), +/// if the automated and the plain member take different lengths, if an unchanged `S` still +/// writes, if a nested VCA's `S` is summed in another order than `controls::effective` (the one +/// author of `S`), or if the fast path decides otherwise than the full path. #[test] -fn an_automated_fader_lane_takes_no_fader_record() { - use BuiltinLaneSelector::{Left, Right}; +fn a_vca_move_writes_the_automated_lanes_offsets_cell() { + use BuiltinLaneSelector::{Both, Left, Right}; + let session_ramps = LiveRamps { + fader_samples: 100, + mute_samples: 300, + pan_samples: 200, + link_samples: 0, + }; let current = normalized(&edited(&with_vca(0.0, 0.0, [false; 2]), |model| { - let ride = left_fader_ride(model); + let second = model.tracks[1].id.clone(); + model.vcas[0].members.push(second); + let ride = fader_ride(model); model.automation.push(ride); })); - let first = track_id(¤t, 0); + let (first, second) = (track_id(¤t, 0), track_id(¤t, 1)); + let run = |current: &SessionModel, edit: &dyn Fn(&mut SessionModel), ramps: &EditRamps| { + let next = edited(current, edit); + classify_live_delta(current, &next, session_ramps, ramps).map(|delta| level(&delta)) + }; let both = |next: &mut SessionModel| { next.vcas[0].fader.left_db = -6.0; next.vcas[0].fader.right_db = -6.0; }; assert_eq!( - classify(¤t, both), - Ok(vec![(first.clone(), vec![fader_db(Right, -6.0, 0)], None)]), - "the automated left lane takes no record" + run(¤t, &both, &EditRamps::new()), + Ok(vec![ + (first.clone(), Vec::new(), [sum(-6.0, 100), sum(-6.0, 100)]), + (second.clone(), vec![fader_db(Both, -6.0, 100)], [None; 2]), + ]), + "the automated member's cell and the plain member's record, at one length" + ); + // The edit's own length reaches both members alike. + let mut ramp = EditRamps::new(); + ramp.set( + &EditRampTarget::Vca(current.vcas[0].id.clone()), + EditRampRow::VcaFader, + Some(256), ); assert_eq!( - classify(¤t, |next| next.vcas[0].fader.left_db = -6.0), - Ok(Vec::new()), - "a move of the automated lane alone is no record" + run(¤t, &both, &ramp), + Ok(vec![ + (first.clone(), Vec::new(), [sum(-6.0, 256), sum(-6.0, 256)]), + (second.clone(), vec![fader_db(Both, -6.0, 256)], [None; 2]), + ]) ); assert_eq!( - classify(¤t, |next| { - next.vcas[0].fader.left_db = -6.0; - next.vcas[0].fader.left_mute = true; - }), - Ok(vec![(first, vec![mute(Left, true, 0)], None)]), + run( + ¤t, + &|next: &mut SessionModel| next.vcas[0].fader.left_db = -6.0, + &EditRamps::new() + ), + Ok(vec![ + (first.clone(), Vec::new(), [sum(-6.0, 100), None]), + (second.clone(), vec![fader_db(Left, -6.0, 100)], [None; 2]), + ]), + "one lane's move writes that lane's cell" + ); + assert_eq!( + run( + ¤t, + &|next: &mut SessionModel| { + next.vcas[0].fader.left_db = -6.0; + next.vcas[0].fader.left_mute = true; + }, + &EditRamps::new() + ), + Ok(vec![ + ( + first.clone(), + vec![mute(Left, true, 300)], + [sum(-6.0, 100), None] + ), + ( + second.clone(), + vec![fader_db(Left, -6.0, 100), mute(Left, true, 300)], + [None; 2] + ), + ]), "the automated lane's mute keeps its record" ); + // Gate 3: a move to the current value, or of a VCA that reaches neither track, writes nothing. + assert_eq!( + run( + ¤t, + &|next: &mut SessionModel| next.vcas[0].fader.left_db = 0.0, + &EditRamps::new() + ), + Ok(Vec::new()) + ); + let lone = normalized(&edited(¤t, |model| { + let mut other = vca(); + other.id = id("other"); + model.vcas.push(other); + })); + assert_eq!( + run( + &lone, + &|next: &mut SessionModel| { + let other = next.vcas.iter_mut().find(|vca| vca.id == id("other")); + other.expect("other").fader.left_db = -6.0; + }, + &EditRamps::new() + ), + Ok(Vec::new()) + ); + // A lane the entry does not drive keeps its `FaderDb` (#1648 D3). + let left_only = normalized(&edited(&with_vca(0.0, 0.0, [false; 2]), |model| { + let ride = left_fader_ride(model); + model.automation.push(ride); + })); + assert_eq!( + run(&left_only, &both, &EditRamps::new()), + Ok(vec![( + first.clone(), + vec![fader_db(Right, -6.0, 100)], + [sum(-6.0, 100), None] + )]), + ); + + // Three nested VCAs reach track 0, declared and nested out of ID order, offsets 24, -24 and + // 2e-30 in ascending ID order. Moving the third to 1e-30 writes the `S` a fresh plan of the + // edited session computes: `(24 + -24) + 1e-30`, not the `0.0` of an order that adds `1e-30` + // before `-24`. + let nested = normalized(&edited(&two_tracks(), |model| { + let track = model.tracks[0].id.clone(); + let vca = |name: &str, db: f32, members: Vec| Vca { + id: id(name), + fader: DualMonoFader { + left_db: db, + right_db: db, + left_mute: false, + right_mute: false, + }, + members, + }; + // `vca-c` holds the track, `vca-a` holds `vca-c`, `vca-b` holds `vca-a`. + model.vcas = vec![ + vca("vca-c", 2e-30, vec![track]), + vca("vca-b", -24.0, vec![id("vca-a")]), + vca("vca-a", 24.0, vec![id("vca-c")]), + ]; + let ride = fader_ride(model); + model.automation.push(ride); + })); + let edit = |next: &mut SessionModel| { + let third = next + .vcas + .iter_mut() + .find(|vca| vca.id == id("vca-c")) + .expect("vca-c"); + third.fader.left_db = 1e-30; + third.fader.right_db = 1e-30; + }; + let after = edited(&nested, edit); + let fresh = + controls::effective(&after, &controls::MonitorOverlay::default()).strips[0].offsets_sum[0]; + assert_eq!(fresh.to_bits(), f64::from(1e-30_f32).to_bits(), "ID order"); + assert_ne!( + fresh.to_bits(), + ((f64::from(24.0_f32) + f64::from(1e-30_f32)) + f64::from(-24.0_f32)).to_bits(), + "another order gives other bits" + ); + assert_eq!( + run(&nested, &edit, &EditRamps::new()), + Ok(vec![( + first.clone(), + Vec::new(), + [sum(fresh, 100), sum(fresh, 100)] + )]) + ); + #[cfg(feature = "test-support")] { let next = edited(¤t, both); @@ -1518,12 +1688,12 @@ fn an_automated_fader_lane_takes_no_fader_record() { control: 1, lane: ParameterChannel::Both, }]; - let full = classify_live_delta(¤t, &next, STEP, &EditRamps::new()); + let full = classify_live_delta(¤t, &next, session_ramps, &EditRamps::new()); let _ = host_core::take_fast_classification_count(); let fast = host_core::classify_live_edit( ¤t, &next, - STEP, + session_ramps, &EditRamps::new(), host_core::MonitorBase::NONE, Some(&rows), @@ -1537,7 +1707,7 @@ fn an_automated_fader_lane_takes_no_fader_record() { delta.derived_owners = None; delta }); - assert_eq!(fast, full, "the fast path suppresses the same lane"); + assert_eq!(fast, full, "the fast path writes the same cells"); } } diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index b0b42ad49..a3a4df253 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -1141,6 +1141,9 @@ fn parity_model() -> SessionModel { /// Render `rig` for `blocks` blocks; with handles, a live mute ramp of 300 samples starts on track /// 1's left lane at block 10 and an unmute ramp at block 20, so a held ramp is reported (D1.2). +/// With handles, the offsets cells take two writes too (#1649 D3): track 0's two lanes a new `S` +/// at block 15 (a jump of 480 samples), and track 1's left lane one at block 20, in the block +/// whose drain starts the unmute ramp, so its jump meets a held gain. #[cfg(feature = "test-support")] fn render_parity(mut rig: Rig, blocks: usize) -> [Vec; 2] { use builtins_compiler::TestOnlyWriteGate; @@ -1163,6 +1166,32 @@ fn render_parity(mut rig: Rig, blocks: usize) -> [Vec; 2] { smoothing_samples: 300, }, ); + if block == 20 { + handles.strip_controls[1].fader.write_offsets( + stamp, + 0, + builtins_compiler::FaderOffsetsRecord { + offsets_sum: -3.0, + smoothing_samples: 200, + }, + ); + } + }); + } + if let (Some(gate), Some(handles)) = (gate.as_mut(), rig.handles.as_mut()) + && block == 15 + { + gate.write_published(|stamp| { + for channel in 0..2 { + handles.strip_controls[0].fader.write_offsets( + stamp, + channel, + builtins_compiler::FaderOffsetsRecord { + offsets_sum: -6.0, + smoothing_samples: 480, + }, + ); + } }); } rig.block(); @@ -1176,9 +1205,14 @@ fn render_parity(mut rig: Rig, blocks: usize) -> [Vec; 2] { /// with the matrix), and prepared between render calls (the split pair), each with a live mute /// and unmute ramp on an automated lane. /// +/// The live variants also write the offsets cells (#1649): a jump on a free lane and one under a +/// held unmute ramp, read by the fader bank and by the oracle's live fader owner alike. +/// /// Test value: red if the oracle's fader owners ignore the program, take no node time or `a(n)`, /// defer or fuse an automated fader past its events, or convert an event differently from the -/// bank: "a placement change moves no bit" would fail for automated sessions. +/// bank: "a placement change moves no bit" would fail for automated sessions. Red too if the +/// oracle does not read the offsets cells, or jumps (or forgets a held lane's target) otherwise +/// than the bank. #[cfg(feature = "test-support")] #[test] fn the_scalar_oracle_renders_the_banks_fader_events() { @@ -1231,6 +1265,13 @@ fn the_scalar_oracle_renders_the_banks_fader_events() { ), ] { assert_ne!(bank, without, "{label}: the automation is heard"); + if label != "no live control" { + assert_ne!( + bank, + plain_on(Backend::Simd4), + "{label}: the offsets writes are heard" + ); + } for plane in 0..2 { assert_eq!( first_difference(&bank[plane], &oracle[plane]), diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index e06e0b463..ca5b5148d 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -10313,6 +10313,7 @@ mod canonical_live { label: &str, before: &session::SessionModel, edit: &SessionEdit, + after: &session::SessionModel, ) { const BLOCKS: u64 = 4; let document = canonical_session_json(before).expect("canonical"); @@ -10352,7 +10353,7 @@ mod canonical_live { (EditPath::Live, before.revision + 1), "{label}: the move is live" ); - let mut committed = model(MEMBER_DB, OFFSET_DB); + let mut committed = after.clone(); committed.revision = before.revision + 1; let ramp = u64::from(committed.control_smoothing_samples().fader); let settled = BLOCKS + ramp.div_ceil(u64::from(STRIP_QUANTUM)) + 1; @@ -10375,8 +10376,8 @@ mod canonical_live { } } - /// TBD48 D2 (browser): a live fader move on a VCA member, and a live VCA offset move over it, - /// each made through the browser's control entry while the caller's thread runs FTZ, DAZ and + /// TBD48 D2 (browser): a live fader move on a VCA member, a live VCA offset move over it, + /// and (#1649) that VCA move over a member whose fader is automated, each made through the browser's control entry while the caller's thread runs FTZ, DAZ and /// round toward zero, render from the end of their ramp the bits of the committed model booted /// on a canonical thread. The pair's composition rounds to another `f32`, and another gain, /// under that word (asserted), so the gate is not vacuous. @@ -10393,20 +10394,55 @@ mod canonical_live { "the gate is vacuous: the hostile word's composition converts to the same gain" ); let member = model(0.0, OFFSET_DB).tracks[MEMBER].id.clone(); + let vca_move = set( + Owner::Vca(StableId::parse("v").expect("VCA ID")), + Section::Own, + 1, + OFFSET_DB, + ); live_move_under_hostile_word( "a live fader move", &model(0.0, OFFSET_DB), &set(Owner::Strip(member), Section::Builtins, 5, MEMBER_DB), + &model(MEMBER_DB, OFFSET_DB), ); live_move_under_hostile_word( "a live VCA move", &model(MEMBER_DB, 0.0), - &set( - Owner::Vca(StableId::parse("v").expect("VCA ID")), - Section::Own, - 1, - OFFSET_DB, - ), + &vca_move, + &model(MEMBER_DB, OFFSET_DB), ); + // #1649: the same VCA move over a member whose left fader is automated, flat at the + // member's dB, writes the lane's offsets cell; render composes the curve with it. + live_move_under_hostile_word( + "a live VCA move on an automated lane", + &automated(model(MEMBER_DB, 0.0)), + &vca_move, + &automated(model(MEMBER_DB, OFFSET_DB)), + ); + } + + /// `model` with track [`MEMBER`]'s left fader automated, flat at [`MEMBER_DB`] (#1649). + fn automated(mut model: session::SessionModel) -> session::SessionModel { + let owner = session::Owner::Strip(model.tracks[MEMBER].id.clone()); + model.automation.push(session::Automation { + id: StableId::parse("member-flat").expect("automation ID"), + target: session::ControlAddress { + owner, + section: session::Section::Builtins, + control: 5, + lane: session::ParameterChannel::Left, + }, + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: 0, + end_sample: 1 << 20, + start_value: MEMBER_DB, + end_value: MEMBER_DB, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + }); + model } } diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index cd55beb02..c32dafa4e 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -4373,3 +4373,277 @@ fn a_plain_session_seek_declares_and_an_anchored_one_does_not_alike_on_both_host ); } } + +/// #1649: [`model_at`] with `t2` riding its fader on both lanes (linear from -3 dB at sample 0 to +/// -15 dB at the run's end, so every grid point moves the lane), VCA `v` holding `t2` and `t3` +/// (`t3` has no automation), and `change` applied. +fn automated_vca_model(rate: u32, change: impl FnOnce(&mut SessionModel)) -> SessionModel { + let mut model = model_at(rate); + model.automation.push(session::Automation { + id: id("t2-fader-ride"), + target: address( + Owner::Strip(id(T2.id)), + Section::Builtins, + FADER_DB, + ParameterChannel::Both, + ), + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: u64::from(QUANTUM) * BLOCKS, + start_value: -3.0, + end_value: -15.0, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + }); + model.vcas[0].members = vec![id(T2.id), id(T3.id)]; + change(&mut model); + model +} + +/// A VCA `name` at `db` on both lanes holding `members`. +fn vca_at(name: &str, db: f32, members: &[&str]) -> Vca { + Vca { + id: id(name), + fader: DualMonoFader { + left_db: db, + right_db: db, + left_mute: false, + right_mute: false, + }, + members: members.iter().map(|member| id(member)).collect(), + } +} + +/// `SET_CONTROL` of VCA `vca`'s offset on both lanes. +fn vca_offset(vca: &str, db: f32, ramp: Option) -> SessionEdit { + SessionEdit::SetControl { + expected_effect: None, + address: address( + Owner::Vca(id(vca)), + Section::Own, + VCA_OFFSET_DB, + ParameterChannel::Both, + ), + value: db, + ramp_samples: ramp, + } +} + +/// The blocks a jump of `jump` samples, the first grid ramp after it (two grid periods of 64) and +/// the plan's latency may still be settling after the edit's block. +fn settle_blocks(browser: &mut Rig, jump: u32) -> u64 { + let latency = browser_plan_latency(browser).0; + (latency + u64::from(jump) + 128).div_ceil(u64::from(QUANTUM)) + 1 +} + +/// One #1649 case on both hosts: `model` plays four blocks, then `edits` commit as one transaction +/// on each host with the same verdict (`path`, one revision) and the same reply bytes. Every block +/// is equal on the two hosts, and from `settle_blocks` after the edit the browser renders the bits +/// of a browser booted on the committed session and fed the same PCM from block 0; when `heard`, +/// that session renders otherwise than `model` there. +fn automated_vca_alike( + label: &str, + model: &SessionModel, + edits: &[SessionEdit], + path: EditPath, + jump: u32, + heard: bool, +) { + const EDIT: u64 = 4; + let mut browser = Rig::new(model); + let mut c_abi = CAbi::new(model); + same_blocks(&mut browser, &mut c_abi, 0..EDIT, label); + let (b, b_reply) = browser.transaction_reply(edits); + let (c, c_reply) = c_abi.transaction(edits); + assert_eq!(b, c, "{label}: one verdict"); + assert_eq!(b_reply, c_reply, "{label}: one reply"); + assert_eq!( + b, + Verdict::Applied { + path, + revision: model.revision + 1 + }, + "{label}: the path" + ); + let committed = snapshot_bytes(|payload, expected| browser.command(payload, expected)); + let mut twin = Rig::boot(&committed, model.sample_rate_hz); + let mut unedited = Rig::new(model); + let settle = EDIT + settle_blocks(&mut browser, jump); + let end = settle + 4; + assert!(end <= BLOCKS, "{label}: the run fits the sources"); + let mut moved = false; + for block in 0..end { + let (t, u) = (twin.step(block), unedited.step(block)); + if block < EDIT { + continue; + } + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{label}: block {block} alike on both hosts"); + if block >= settle { + assert_eq!( + b, t, + "{label}: block {block} renders the committed session's boot" + ); + moved |= t != u; + } + } + assert_eq!(moved, heard, "{label}: heard exactly when a value moves"); +} + +/// #1649 gates 1 and 3 (both hosts). A -6 dB move of `v` over the automated member `t2` (and the +/// plain member `t3`) is `live` on both hosts with the same reply, renders the same bits on both, +/// and from the end of the jump and the first grid ramp after it renders the committed session's +/// boot: with the session's length, and with the edit's own 256 samples (a longer ramp on either +/// member would still differ in that shorter window). A move of `v` to its current value is +/// `model_only` and moves no bit. +/// +/// Test value: red if the browser's VCA move does not reach the automated lane's offsets cell +/// (the ride keeps the old composition), if the two hosts differ, or if an unchanged offset +/// retargets the lane. +#[test] +fn a_vca_move_over_an_automated_member_is_live_alike_on_both_hosts() { + for rate in RATES { + let model = automated_vca_model(rate, |_| {}); + let fader = model.control_smoothing_samples().fader; + automated_vca_alike( + &format!("{rate} Hz, the session's length"), + &model, + &[vca_offset(VCA.id, -6.0, None)], + EditPath::Live, + fader, + true, + ); + automated_vca_alike( + &format!("{rate} Hz, 256 samples"), + &model, + &[vca_offset(VCA.id, -6.0, Some(256))], + EditPath::Live, + 256, + true, + ); + automated_vca_alike( + &format!("{rate} Hz, the current value"), + &model, + &[vca_offset(VCA.id, 0.0, None)], + EditPath::ModelOnly, + fader, + false, + ); + } +} + +/// #1649 gate 2 (both hosts). Ten live moves of `v` before one render give the bits of a twin that +/// made only the last, on both hosts (render reads the newest slot of the offsets cell, whole). +/// Three nested VCAs reach `t2`, declared and nested out of ID order, at 24, -24 and 2e-30 dB in +/// ascending ID order: a move of the third to 1e-30 is `live` and renders the committed session's +/// boot (the `S` bits it writes are the classifier gate's, +/// `a_vca_move_writes_the_automated_lanes_offsets_cell`). +/// +/// Test value: red if render applies an older slot than the newest committed one, if a slot mixes +/// two writes, or if a nested reach is composed otherwise than preparation. +#[test] +fn many_vca_moves_before_one_render_are_the_last_alike_on_both_hosts() { + const EDIT: u64 = 4; + for rate in RATES { + let model = automated_vca_model(rate, |_| {}); + let mut many = Rig::new(&model); + let mut one = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + same_blocks(&mut many, &mut c_abi, 0..EDIT, "before the moves"); + for block in 0..EDIT { + one.step(block); + } + for step in 1..=10 { + let db = if step == 10 { -6.0 } else { -(step as f32) }; + let edit = [vca_offset(VCA.id, db, None)]; + assert_eq!(many.transaction(&edit), c_abi.transaction(&edit).0); + } + one.transaction(&[vca_offset(VCA.id, -6.0, None)]); + for block in EDIT..EDIT + 24 { + let (m, c, o) = (many.step(block), c_abi.step(block), one.step(block)); + assert_eq!(m, c, "{rate} Hz: block {block} alike on both hosts"); + assert_eq!(m, o, "{rate} Hz: block {block} is the last move's"); + } + + let nested = automated_vca_model(rate, |model| { + model.vcas = vec![ + vca_at("vca-c", 2e-30, &[T2.id]), + vca_at("vca-b", -24.0, &["vca-a"]), + vca_at("vca-a", 24.0, &["vca-c"]), + ]; + }); + automated_vca_alike( + &format!("{rate} Hz, nested"), + &nested, + &[vca_offset("vca-c", 1e-30, None)], + EditPath::Live, + nested.control_smoothing_samples().fader, + false, + ); + } +} + +/// #1649 gate 4 (both hosts). Each edit that changes which VCAs reach the automated member `t2`, or +/// how many VCAs the session has, is `live` alike on both hosts and from the end of the jump +/// renders the committed session's boot: `t2` added to a second VCA; that VCA (holding `t2`) made +/// a member of `v`; a new VCA that reaches `t2`; the first VCA of a session that had none; a VCA +/// that reaches `t2` removed. +/// +/// Test value: red if any of these rebuilds or differs between the hosts, if a lane no VCA reached +/// had no cell to write when a VCA is added, or if a removed VCA leaves its offset in `S`. +#[test] +fn vca_reach_and_count_changes_over_an_automated_member_are_live_alike_on_both_hosts() { + let upsert = |vca: Vca| SessionEdit::UpsertVca { vca }; + for rate in RATES { + let base = automated_vca_model(rate, |model| { + model.vcas = vec![ + vca_at(VCA.id, -3.0, &[T2.id, T3.id]), + vca_at("w", -4.0, &[T1.id]), + ]; + }); + let nested = automated_vca_model(rate, |model| { + model.vcas = vec![vca_at(VCA.id, -3.0, &[T3.id]), vca_at("w", -4.0, &[T2.id])]; + }); + let bare = automated_vca_model(rate, |model| model.vcas.clear()); + let fader = base.control_smoothing_samples().fader; + let cases: [(&str, &SessionModel, SessionEdit); 5] = [ + ( + "t2 added to a second VCA", + &base, + upsert(vca_at("w", -4.0, &[T1.id, T2.id])), + ), + ( + "the VCA holding t2 nested in v", + &nested, + upsert(vca_at(VCA.id, -3.0, &[T3.id, "w"])), + ), + ( + "a new VCA that reaches t2", + &base, + upsert(vca_at("x", -2.0, &[T2.id])), + ), + ( + "the first VCA of the session", + &bare, + upsert(vca_at(VCA.id, -6.0, &[T2.id, T3.id])), + ), + ( + "a VCA that reaches t2 removed", + &base, + SessionEdit::RemoveVca { vca_id: id(VCA.id) }, + ), + ]; + for (label, model, edit) in cases { + automated_vca_alike( + &format!("{rate} Hz, {label}"), + model, + &[edit], + EditPath::Live, + fader, + true, + ); + } + } +} From d300cd32136c62784c5ad7bf2b1e2d9b037d0bf8 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 20:02:26 +0000 Subject: [PATCH 18/35] Carry fader automation state across a swap (#1648b) FaderBankProcessor::adopt_lane moves each automated channel's cell state and held-ramp obligation (paired by strip and channel) and clears fresh; the successor keeps its own offsets cell and S. An unchanged table continues verbatim; a changed one repositions, re-reports the held ramp and jumps over J when the composed gain differs (adoption test). A removed ride is released by the strip join's FaderDb record. Gates 2-4 and 6 on the C ABI, browser parity leg. Realtime policy 159; pcm_digest unchanged. Co-Authored-By: Claude Opus 5.5 --- ...assify-and-carry-fader-automation-edits.md | 97 ++++++++ crates/builtins-compiler/src/lib.rs | 189 +++++++++++++++- crates/builtins/src/lib.rs | 12 + crates/capi/src/runtime/live_tests.rs | 214 +++++++++++++++++- crates/controls/src/automation.rs | 34 ++- crates/controls/src/lib.rs | 2 +- crates/graph/src/lib.rs | 18 +- crates/graph/src/runtime.rs | 8 +- crates/host-core/src/prepare.rs | 84 ++++++- .../tests/stored_fader_automation.rs | 60 +++-- crates/host-core/tests/successor_swap.rs | 106 +++++++++ hosts/host-web/tests/control_parity.rs | 41 ++++ 12 files changed, 819 insertions(+), 46 deletions(-) diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index 192c49bd9..29f5b0240 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -467,3 +467,100 @@ fader stage carries its ramp as `Same` when no static level moved, but the cell not carry (`adopt_lane`), so the successor's cell starts at its prepared value. The D3 hook already makes the join read a static change on an automated lane as `Same`, so #1648b's carry sees no retarget record for it. + +## Decision record: #1648b (batch 17) + +The carry (D2, the batch 13b items, gates 2-4 and 6). Uncommitted on `codex/controls-batch-17k` +over `5e695ecb6` for the coordinator. + +**What shipped.** + +- **The seam (`builtins-compiler`).** `FaderBankProcessor::adopt_lane` (both bank kinds: the fused + fader-matrix bank forwards to it) calls `LaneFaderAutomation::adopt` after the stage import. Each + automated channel of the successor lane takes, by its stable address (the strip move pairs the + strip; the channel index is the lane), the carried `CellState` and `HeldRampObligation` + (`owed`, `due_at_entry`), and `fresh` is cleared. It moves only the event state and the + obligation: the successor keeps its own offsets cell and `S`. Plain copies at the swap block, no + allocation. Each channel then owes its first block an `Adoption`: + - `None`: the table continues bit for bit (`continued_cells`), at the same `a(n)` and the same + `S` bits. The state continues verbatim, so the events are the uninterrupted ones (gate 3). + - `Compare`: the same table and `a(n)`, but `S` moved. Only the adoption rule runs. + - `Reposition`: a changed table, a moved `a(n)`, or a gained cell (no carried cell). The cursor + is repositioned in the cell's own table at the block's node time. The stage's held ramp is + reported again with `hold_until`, because `reposition` drops it. Then the adoption rule runs. +- **The adoption rule (departure 4).** At the first block, if the span has no seek step, this + test runs: `checked_fader_gain(vca_compose_db(curve(t0), S_successor))` against the stage's + fader gain (`BuiltinFaderBank::fader_gain`, the gain the lane aims for whatever its mute). If + the bits differ, the block opens with a ramped landing (`SeekMode::Ramp` step at offset 0, over + the block's fader jump length, to the curve at the jump's completion). That sets the cell's + target and holds the grid for the jump. Inside a held ramp the stage only remembers it, and + #1647c's obligation moves the lane at `E`. If the bits are equal, a repositioned cell is seeded + with `curve(t0)` (`seed_target`). +- **The join (`host-core`).** `StripJoin::continued_cells` comes from + `controls::continued_fader_lanes(committed, model, strip)`: both models drive the lane with + bit-identical segments. It reaches the fader move as `graph::GraphStripMove::continued_cells`, + and `adopt_strip_lane` gains that argument. `committed` is P0's model across a withdrawn plan, + so the flag needs no `across_withdrawn` change (#1698). +- **The release ("a cell lost").** `EffectiveDiff::join_level_records` (used by `join_strips` + only) adds a `FaderDb` of the lane's effective `next` value, over the lane's fader length. It + does this for each lane that `current` automates and `next` does not, and that has no + `FaderDb` yet. The successor's cells receive it as a carry retarget (#1277 D5). + +**Departures.** + +1. **No release cell.** A released lane is moved by the join's carry retarget record, not by a + table-less cell in the successor. The successor has no slot storage for a lane it does not + automate, and a cell would need new preparation plumbing and bytes for every such lane. The + record goes to the static value over the fader length, from whatever gain the stage holds. + The stage's live-write rule then gives the batch 13b behaviour: inside an unmute ramp the write + retargets and ends the hold, and while muted the stage remembers it for the unmute. If the + target is already equal, the ramp goes from `g` to `g`. A plan with no live cells cannot + receive a record, so there the strip restarts at its prepared static value. +2. **Verbatim continuation for an unchanged table.** The filed rule ("reposition each carried + cursor; jump if `curve(t0)` differs") jumps on every swap of a moving curve. Mid-ride the + carried target is the grid's completion value, not `curve(t0)`, and gate 3 measured this (red + with `Reposition` forced). So a cell whose table, `a(n)` and `S` all continue keeps its state + as it is. The table identity is decided at preparation from the two models (an exact segment + compare, off render) and carried in the strip move, so render never compares tables. +3. **A seek in the adoption block wins.** If the adoption block has a seek step, the seek lands + exactly, as #1647 D3 says, and no adoption jump is made. +4. **Superseded #1647b test rewritten.** `a_lane_a_successor_adds_starts_at_its_curve` (a gained + lane on a carried stage landed a `Set`) is now `a_lane_a_successor_adds_jumps_to_its_curve` + (`crates/host-core/tests/stored_fader_automation.rs`). Per D2 the lane ramps over `J` and then + equals the fresh reference. The `Set` of a restarted stage's fresh lane (#1647 D2) is no longer + gated by a host-core test; the C ABI raised-floor seek test still reaches the fresh-lane path. +5. **Gates 2 and 4 use a stateless session** (`stateless_session`, a long source). A fresh + reference after a session seek matches a playing plan only when no strip has IIR or other + history state. Gate 3 runs on the same session. +6. **Out-of-path edits, one each:** `crates/builtins/src/lib.rs` + (`BuiltinFaderBank::fader_gain`, the one accessor) and + `crates/host-core/tests/stored_fader_automation.rs` (deviation 4). The scalar oracle never + adopts (strip carry is bank-only), so its `ChannelHold::fader_gain` is `None`. + +**Gates and tests (all pass, x86-64 AVX2).** + +- Gate 2: `a_changed_fader_ride_rebuilds_and_carries_into_its_jump` + (`crates/capi/src/runtime/live_tests.rs`). Path `rebuild`, watermark `EXACT` at `T`, source + generation still 1. In the jump's last block the lane still differs from the seeked fresh + reference. From `T + 960 + 63` every sample is equal. +- Gate 3: `an_unchanged_fader_ride_carries_with_no_jump` (same file). Every block from the swap is + bit-identical to the uninterrupted render. +- Gate 4: `a_removed_fader_ride_releases_to_the_static_fader` (same file). The lane differs inside + the release. From the release's last sample it equals the seeked fresh static plan. +- Browser leg: `a_fader_automation_edit_rebuilds_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`, four rates) also changes and then removes the ride. + Both get the same verdict and reply (`rebuild`), and the same bits across each carried swap. +- Gate 6: `fader_automation_carry::carrying_fader_automation_allocates_and_frees_nothing` + (`crates/host-core/tests/successor_swap.rs`, Simd8 and Simd4, live controls). It covers a + changed ride, a gained left cell and a released right cell. The hand-over plus the successor's + first block give `((0, 0, 0), (0, 0))` and `Carried`. A kept ride with an added muted track is + bit-identical to the uninterrupted run. +- Red on revert, checked once each: with no `adopt` call, gates 2 and 3 and gate 6's kept-ride + check go red. With `Reposition` forced, gate 3 and gate 6's kept-ride check go red. With the + join reading `level_records` (no release), gate 4 goes red. +- `realtime-policy`: ok, 159 regions in 33 files (unchanged). `./target/release/audit capi`: + `pcm_digest` `bffc21e857b5e134` (unchanged), 0 violations, 0 allocations. + `check-capi-abi`, `check-host-core-policy`, `check-graph-policy` and `check-workspace-policy` + all pass. +- Bytes: `Adoption` (one byte) sits in `AutomatedFaderCell`'s padding and + `GraphStripMove::continued_cells` in the move's padding. No existing byte gate moved. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 14d3d78e2..dd9c1cbde 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -659,6 +659,25 @@ struct AutomatedFaderCell { offsets_ramp: u32, /// The held-ramp obligation (#1647 D1.6, batch 13b amendment). obligation: HeldRampObligation, + /// What the cell's first block after a plan swap that carried its lane owes (#1648 D2). + adoption: Adoption, +} + +/// What an automated channel's first block after a plan swap owes (#1648 D2). Set by +/// [`LaneFaderAutomation::adopt`] at the swap block, taken by the lane's next block, which knows +/// the node time. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Adoption { + /// Nothing: the cell has rendered a block since it was prepared or adopted, or it continues + /// its carried state verbatim (the same table, `a(n)` and `S`: the uninterrupted events). + None, + /// The cell continues its carried table and cursor, but its `S` changed: the adoption rule + /// alone (the jump when the composed gain differs from the stage's). + Compare, + /// The cell's table is not the one its state was built on (a changed or gained curve, or a + /// moved `a(n)`): it repositions its cursor in its own table, takes the stage's held ramp + /// again, then applies the adoption rule. + Reposition, } /// One automated channel's obligation to move to its cell's target where a held ramp ends with @@ -728,6 +747,7 @@ impl LaneFaderAutomation { offsets_sum: cell.offsets_sum, offsets_ramp: cell.offsets_ramp, obligation: HeldRampObligation::default(), + adoption: Adoption::None, } }) }), @@ -741,6 +761,48 @@ impl LaneFaderAutomation { } // REALTIME_POLICY_BEGIN + /// #1648 D2: this lane's stage was carried from `predecessor`'s lane at the swap block (the + /// predecessor lane's automation, `None` when it had none). Each automated channel takes the + /// carried cell's event state and held-ramp obligation by its stable address (the strip, row 5 + /// and the channel; the strip move paired the strip) and owes its first block an adoption: + /// none when its table continues bit for bit (`continued_cells`, from the join) at the same + /// `a(n)` and `S`, the adoption rule alone when only `S` moved, and a reposition otherwise. A + /// channel the predecessor did not automate (a gained cell) keeps its prepared state and + /// repositions. The lane is not fresh: its stage holds the carried gain, so no `Set` lands + /// (#1647 D2 is for a lane that starts at its prepared value). A channel the successor does + /// not automate is released by the join's retarget record, not here. Allocation-free: plain + /// copies. + fn adopt(&mut self, predecessor: Option<&Self>, continued_cells: u8) { + self.fresh = false; + let arrival = self.arrival; + for (channel, cell) in self.channels.iter_mut().enumerate() { + let Some(cell) = cell else { + continue; + }; + let carried = predecessor.and_then(|predecessor| { + predecessor + .channels + .get(channel)? + .as_ref() + .map(|carried| (carried, predecessor.arrival)) + }); + cell.adoption = match carried { + Some((carried, carried_arrival)) => { + cell.state = carried.state; + cell.obligation = carried.obligation; + if continued_cells & (1 << channel) == 0 || carried_arrival != arrival { + Adoption::Reposition + } else if carried.offsets_sum.to_bits() == cell.offsets_sum.to_bits() { + Adoption::None + } else { + Adoption::Compare + } + } + None => Adoption::Reposition, + }; + } + } + /// Take the block's node-time mapping and the fader row's jump length (#1647a, R17-3): the /// one path of both fader owners, the bank per lane and the scalar oracle per strip. fn take_block_time(&mut self, time: &GraphBlockTime<'_>, fader_jump: Option) { @@ -827,6 +889,10 @@ struct ChannelHold { muted: bool, /// Frames left in the channel's ramp. remaining: u32, + /// The fader gain the stage aims for, whatever its mute (#1648 D2: an adopted cell compares + /// its curve with it). `None` on the scalar oracle, which no plan swap carries (its fader + /// owners are bound nodes, not bank lanes). + fader_gain: Option, } /// One channel's block cut at the end `E` of a held ramp (#1647 D1.6, constraint 2): the first @@ -999,6 +1065,7 @@ impl<'a> FaderEventDriver<'a> { state, offsets_sum, obligation, + adoption, .. }) = cell.as_mut() else { @@ -1014,7 +1081,7 @@ impl<'a> FaderEventDriver<'a> { refused(&mut driver); continue; }; - let step = span + let mut step = span .step .map(|step| (step.offset, step.timeline)) .or_else(|| { @@ -1022,7 +1089,25 @@ impl<'a> FaderEventDriver<'a> { (first_block && state.target().map(f32::to_bits) != Some(value.to_bits())) .then_some((0, span.start)) }); - let (free_from, at) = obligation.block(hold(lane, channel), frames); + let hold_now = hold(lane, channel); + let mut mode = controls::FADER_CELL_RULE.seek; + if settle_adoption( + core::mem::replace(adoption, Adoption::None), + AdoptionBlock { + program: &cell_program, + span, + offsets_sum: *offsets_sum, + hold: hold_now, + }, + state, + ) && step.is_none() + { + // The adoption jump (#1648 D2): a ramped landing at the block's first sample + // over the block's jump length, to the curve at the jump's completion. + step = Some((0, span.start)); + mode = automation::SeekMode::Ramp; + } + let (free_from, at) = obligation.block(hold_now, frames); let end = at.map(|at| hold_end(span.start, step, at, frames)); let first = match end { Some(Some(end)) => automation::NodeSpan::new( @@ -1050,7 +1135,7 @@ impl<'a> FaderEventDriver<'a> { cell_program, state, node, - controls::FADER_CELL_RULE.seek, + mode, jump, )), base: 0, @@ -1125,6 +1210,7 @@ impl HeldRampObligation { held: true, muted: false, remaining, + .. }) if remaining > 0 => { if remaining < frames { (remaining, Some(remaining)) @@ -1144,6 +1230,63 @@ impl HeldRampObligation { } } +/// What the adoption rule of one automated channel reads at its first block after a plan swap. +struct AdoptionBlock<'p, 'a> { + program: &'p automation::CellProgram<'a>, + span: TimelineSpan, + offsets_sum: f64, + hold: Option, +} + +/// #1648 D2, at an adopted channel's first block: settle what [`LaneFaderAutomation::adopt`] left +/// owed, and whether the block must open with the adoption jump. +/// +/// A [`Adoption::Reposition`] places the cursor at the block's node time in the cell's own table +/// (#1637 D4) and reports the stage's held ramp again (batch 13b: `reposition` drops the one the +/// carried state held). Then, for a reposition or [`Adoption::Compare`], the adoption rule: when +/// the curve at the block's first sample, composed with this plan's `S` +/// (`checked_fader_gain(vca_compose_db(curve, S))`, #1648 departure 4), differs in bits from the +/// gain the stage aims for, the block opens with a jump (returns `true`). Otherwise a repositioned +/// cell takes the curve's value as its current target (`seed_target`: the stage already holds its +/// gain), and a continued one keeps its carried state. A block with a seek step lands the seek +/// instead (the caller's step wins): the seek sets the lane exactly where it applies. +fn settle_adoption( + adoption: Adoption, + block: AdoptionBlock<'_, '_>, + state: &mut automation::CellState, +) -> bool { + let AdoptionBlock { + program, + span, + offsets_sum, + hold, + } = block; + if adoption == Adoption::None { + return false; + } + if adoption == Adoption::Reposition { + state.reposition(program, span.start); + if let Some(ChannelHold { + held: true, + remaining, + .. + }) = hold + && let Some(last) = remaining.checked_sub(1) + && let Some(end) = node_time_at(span, u64::from(last)) + { + state.hold_until(end); + } + } + let value = automation::value_at(program.segments(), span.start); + let differs = FaderEventDriver::gain(value, offsets_sum) + .zip(hold.and_then(|hold| hold.fader_gain)) + .is_some_and(|(gain, stage)| gain.to_bits() != stage.to_bits()); + if !differs && adoption == Adoption::Reposition { + state.seed_target(value); + } + differs +} + /// The cut of a block of `frames` samples whose offset 0 is node time `start`, with seek step /// `step`, at offset `at`; `None` past the `i64` range (unreachable: the caller checked the /// block's span). @@ -2586,8 +2729,15 @@ impl FaderBankProcessor { /// Copy `predecessor`'s lane `predecessor_lane` into lane `lane` (#1277 D7, D8). The cell /// reader stays: the successor's own cells take every later value, and it takes the carry - /// marks of the values the carry drain applied (#1630 f). - fn adopt_lane(&mut self, lane: usize, predecessor: &Self, predecessor_lane: usize) -> bool { + /// marks of the values the carry drain applied (#1630 f). The lane's stored automation takes + /// the predecessor lane's event state (#1648 D2, [`LaneFaderAutomation::adopt`]). + fn adopt_lane( + &mut self, + lane: usize, + predecessor: &Self, + predecessor_lane: usize, + continued_cells: u8, + ) -> bool { let adopted = predecessor .bank .export_lane(predecessor_lane) @@ -2599,6 +2749,15 @@ impl FaderBankProcessor { |reader| &mut reader.marks, |reader| &reader.marks, ); + if let Some(Some(automation)) = self.automation.get_mut(lane) { + automation.adopt( + predecessor + .automation + .get(predecessor_lane) + .and_then(Option::as_ref), + continued_cells, + ); + } } adopted } @@ -2675,12 +2834,15 @@ impl GraphPreparedBuiltinBankProcessor for FaderBankProcessor { lane: usize, predecessor: &mut dyn GraphPreparedBuiltinBankProcessor, predecessor_lane: usize, + continued_cells: u8, ) -> bool { owner == BuiltinStripOwner::Fader && predecessor .as_any_mut() .and_then(|any| any.downcast_mut::()) - .is_some_and(|predecessor| self.adopt_lane(lane, predecessor, predecessor_lane)) + .is_some_and(|predecessor| { + self.adopt_lane(lane, predecessor, predecessor_lane, continued_cells) + }) } /// Issue #1277 D8: apply every pending cell value of the carried lanes with every revision, @@ -2770,6 +2932,7 @@ fn bank_hold(bank: &BuiltinFaderBank) -> impl Fn(usize, usize) -> Option bool { + let _ = continued_cells; owner == BuiltinStripOwner::Matrix && predecessor .as_any_mut() @@ -3450,15 +3615,18 @@ impl GraphPreparedBuiltinBankProcessor for FaderMatrixBankProcessor { lane: usize, predecessor: &mut dyn GraphPreparedBuiltinBankProcessor, predecessor_lane: usize, + continued_cells: u8, ) -> bool { predecessor .as_any_mut() .and_then(|any| any.downcast_mut::()) .is_some_and(|predecessor| match owner { - BuiltinStripOwner::Fader => { - self.fader - .adopt_lane(lane, &predecessor.fader, predecessor_lane) - } + BuiltinStripOwner::Fader => self.fader.adopt_lane( + lane, + &predecessor.fader, + predecessor_lane, + continued_cells, + ), BuiltinStripOwner::Matrix => { self.matrix .adopt_lane(lane, &predecessor.matrix, predecessor_lane) @@ -6863,6 +7031,7 @@ fn render_oracle_fader( held: fader.gain_held(channel)?, muted: fader.is_muted(channel), remaining: fader.ramp_remaining(channel)?, + fader_gain: None, }) }, ); diff --git a/crates/builtins/src/lib.rs b/crates/builtins/src/lib.rs index 7a47da803..e8d73531a 100644 --- a/crates/builtins/src/lib.rs +++ b/crates/builtins/src/lib.rs @@ -4628,6 +4628,18 @@ impl BuiltinFaderBank { Some(per_width!(FaderStageKernel(stage) in &self.stage => stage.is_muted(lane, channel))) } + /// The fader gain a member lane aims for on `channel` (`0` left, `1` right), whatever its + /// mute: the gain an unmute retargets back to, and the one a gain operation last set or + /// remembered (#1648 D2 compares a carried lane's with its adopted curve). `None` for a + /// padding lane or a channel above `1`. + #[must_use] + pub fn fader_gain(&self, lane: usize, channel: usize) -> Option { + if lane >= self.members || channel > 1 { + return None; + } + Some(per_width!(FaderStageKernel(stage) in &self.stage => stage.fader_gain[channel][lane])) + } + /// Frames left in a member lane's ramp on `channel`; `0` when settled. `None` for a padding /// lane or a channel above `1`. #[must_use] diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index b583c35b6..134c0cb11 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -8,7 +8,7 @@ use super::tests::{ SESSION, add_muted_track, boxed_c_children_with_limits, command_bytes, command_bytes_at_revision, command_c, control_list_all_c, control_list_c, control_state_all_c, control_state_c, counter_telemetry_for, event_c, generated_parity_session, limits, - lossy_counter_snapshots, submit_c, + lossy_counter_snapshots, stateless_session, submit_c, }; use super::*; use protocol::{ExpectedRevision, SessionEdit, SessionRevision}; @@ -8939,3 +8939,215 @@ fn a_successor_prepared_with_a_control_smoothing_edit_holds_the_new_lengths() { "the successor's cell holds the committed lengths" ); } + +/// #1648b: the fader ride of gates 2-4, on the first track's both lanes: linear from `0` dB at +/// timeline 0 to `end_db` at 6,000, so it moves through every adoption block below. +fn ride_entry(track: &StableId, end_db: f32) -> session::Automation { + session::Automation { + id: StableId::parse("fader-ride").expect("automation ID"), + target: session::ControlAddress { + owner: session::Owner::Strip(track.clone()), + section: session::Section::Builtins, + control: 5, + lane: session::ParameterChannel::Both, + }, + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: 6_000, + start_value: 0.0, + end_value: end_db, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + } +} + +/// #1648b: a stateless session (no effect, no input filter, so a fresh plan's strips render a +/// playing plan's bits once their faders agree) with a long source and, when `end_db` is given, +/// [`ride_entry`] on its first track. +fn ride_document(end_db: Option) -> String { + let mut model = stateless_session(48_000); + model.sources[0].frames = SOURCE_FRAMES; + if let Some(end_db) = end_db { + let track = model.tracks[0].id.clone(); + model.automation.push(ride_entry(&track, end_db)); + } + session::canonical_session_json(&model).expect("canonical ride session") +} + +/// #1648b: send `edits` on `rig`, which must answer `rebuild`, and render until render adopts the +/// successor. The watermark then publishes the revision `EXACT` at the adoption block (#1314), +/// and the source was never reseeked. Returns the adoption block's first sample `T` and the +/// blocks rendered from it on, the adoption block first, `blocks` long. +fn rebuild_and_adopt(rig: &mut Rig, edits: &[SessionEdit], blocks: usize) -> (u64, Vec>) { + let bytes = rig.transaction(edits); + let (result, response) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK); + assert_eq!(edit_path_of(&response), protocol::EditPath::Rebuild); + let revision = rig.summary().0; + let mut rendered = Vec::new(); + while rig.summary().3 != 0 { + rendered.push((rig.block, rig.step())); + assert!(rendered.len() < 16, "the candidate is adopted"); + } + let (applied, first, flags, _) = watermark(rig); + assert_eq!( + (applied, flags), + (revision, crate::OUTCOME_EXACT), + "the revision completes exactly at adoption" + ); + let adoption = first / rig.quantum as u64; + let mut from: Vec> = rendered + .into_iter() + .filter(|(block, _)| *block >= adoption) + .map(|(_, pcm)| pcm) + .collect(); + assert!(!from.is_empty(), "the adoption block was rendered"); + while from.len() < blocks { + from.push(rig.step()); + } + let (result, report) = crate::ffi::test_source_seek_report(rig.session, SOURCE); + assert_eq!(result, crate::RESULT_OK); + assert_eq!(report.generation, 1, "no source is reseeked"); + (first, from) +} + +/// #1648b: a fresh plan of `document`, session-seeked to timeline sample `timeline` (#1642) and +/// fed the PCM from there: `blocks` blocks. +fn seeked_reference(document: &str, timeline: u64, blocks: usize) -> Vec> { + let mut rig = Rig::new(document); + rig.generation = 2; + assert_eq!( + crate::ffi::test_session_seek(rig.session, 2, timeline, None), + crate::RESULT_OK + ); + rig.fed = timeline; + (0..blocks).map(|_| rig.step()).collect() +} + +/// One plane of `blocks`, sample by sample, as bits. +fn plane_bits(blocks: &[Vec], plane: usize, quantum: usize) -> Vec { + blocks + .iter() + .flat_map(|block| block[plane * quantum..(plane + 1) * quantum].iter()) + .map(|sample| sample.to_bits()) + .collect() +} + +/// #1648b blocks: blocks rendered from the adoption block in gates 2 and 4. +const ADOPTED_BLOCKS: usize = 16; + +/// #1648 gate 2. A playing session with a linear fader ride on track 0 takes an +/// `UpsertAutomation` that changes the ride: the path is `rebuild`, the watermark publishes the +/// revision `EXACT` at the adoption block `T`, and no source is reseeked. The carried lane jumps +/// to the new curve over the fader jump length `J` (960 samples, the default table at 48 kHz): +/// it still differs from a fresh plan of the new session, session-seeked to `T` and fed the same +/// PCM, in the jump's last block; and from the completion sample of the first grid ramp that +/// starts after the jump ends (`T + J + 63`), every sample equals that reference. +/// +/// Test value: red if the cell does not carry (the lane restarts with a `Set`), its cursor is not +/// repositioned in the new table, or the adoption jump is missing or not `J` long. +#[test] +fn a_changed_fader_ride_rebuilds_and_carries_into_its_jump() { + let (old, new) = (ride_document(Some(-12.0)), ride_document(Some(-24.0))); + let mut rig = Rig::new(&old); + for _ in 0..4 { + rig.step(); + } + let track = parse_session_json(&old).expect("model").tracks[0] + .id + .clone(); + let edit = SessionEdit::UpsertAutomation { + automation: ride_entry(&track, -24.0), + }; + let (adoption, live) = rebuild_and_adopt(&mut rig, &[edit], ADOPTED_BLOCKS); + assert_eq!( + rig.model().automation, + parse_session_json(&new).expect("new model").automation + ); + let reference = seeked_reference(&new, adoption, ADOPTED_BLOCKS); + let jump = 960_usize; + let settled = jump + 63; + let quantum = rig.quantum; + for plane in 0..2 { + let (live, reference) = ( + plane_bits(&live, plane, quantum), + plane_bits(&reference, plane, quantum), + ); + assert_ne!( + live[jump - quantum..jump], + reference[jump - quantum..jump], + "plane {plane}: the lane is still in its adoption jump" + ); + assert_eq!( + live[settled..], + reference[settled..], + "plane {plane}: the new curve's bits from the first grid ramp after the jump" + ); + } +} + +/// #1648 gate 3. A transaction that adds a muted track and keeps the ride rebuilds, and every +/// block from the swap on is bit-identical to the uninterrupted render of the old session. +/// +/// Test value: red if adoption emits a jump to an unchanged target, or the carried cell restarts +/// or repositions (a moving curve's in-flight state would be dropped). +#[test] +fn an_unchanged_fader_ride_carries_with_no_jump() { + let document = ride_document(Some(-12.0)); + let model = parse_session_json(&document).expect("model"); + let mut rig = Rig::new(&document); + let mut uninterrupted = Rig::new(&document); + for _ in 0..4 { + assert_eq!(bits(&rig.step()), bits(&uninterrupted.step()), "warm-up"); + } + let (adoption, live) = rebuild_and_adopt(&mut rig, &add_muted_track(&model, "added"), 12); + let mut reference = Vec::new(); + while (uninterrupted.block as usize) * uninterrupted.quantum < adoption as usize { + uninterrupted.step(); + } + for _ in 0..live.len() { + reference.push(uninterrupted.step()); + } + for (index, (live, reference)) in live.iter().zip(&reference).enumerate() { + assert_eq!(bits(live), bits(reference), "block {index} from the swap"); + } +} + +/// #1648 gate 4. Removing the ride rebuilds; the released lane ramps from the curve's last target +/// to its static value over the fader jump length, and from the end of that release every sample +/// equals a fresh plan of the new session (a static fader), session-seeked to the adoption block. +/// +/// Test value: red if a removed entry leaves the lane on its last automated value, or steps it. +#[test] +fn a_removed_fader_ride_releases_to_the_static_fader() { + let (old, new) = (ride_document(Some(-12.0)), ride_document(None)); + let mut rig = Rig::new(&old); + for _ in 0..8 { + rig.step(); + } + let edit = SessionEdit::RemoveAutomation { + automation_id: StableId::parse("fader-ride").expect("automation ID"), + }; + let (adoption, live) = rebuild_and_adopt(&mut rig, &[edit], ADOPTED_BLOCKS); + let reference = seeked_reference(&new, adoption, ADOPTED_BLOCKS); + let jump = 960_usize; + let quantum = rig.quantum; + for plane in 0..2 { + let (live, reference) = ( + plane_bits(&live, plane, quantum), + plane_bits(&reference, plane, quantum), + ); + assert_ne!( + live[jump - quantum..jump - 1], + reference[jump - quantum..jump - 1], + "plane {plane}: the lane is still in its release" + ); + assert_eq!( + live[jump - 1..], + reference[jump - 1..], + "plane {plane}: the static fader's bits from the release's last sample" + ); + } +} diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index 119efb7eb..c0a3c1f69 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -488,9 +488,13 @@ pub fn rendered_automation_changed(current: &SessionModel, next: &SessionModel) /// Whether two entries have the same `id`, `target` and `segments`, `f32` values by bits. fn same_curve(before: &Automation, after: &Automation) -> bool { - before.id == after.id - && before.target == after.target - && before.segments.len() == after.segments.len() + before.id == after.id && before.target == after.target && same_segments(before, after) +} + +/// Whether two entries have the same `segments`, `f32` values by bits: the same table, whatever +/// their `id`, `target` or offset. +fn same_segments(before: &Automation, after: &Automation) -> bool { + before.segments.len() == after.segments.len() && before .segments .iter() @@ -505,6 +509,30 @@ fn same_curve(before: &Automation, after: &Automation) -> bool { }) } +/// The lanes of strip `strip`'s fader (`[left, right]`) that both models automate with the same +/// table (#1648 D2): the entry that drives the lane in `current` and the one that drives it in +/// `next` ([`fader_automation`]) have the same segments, `f32` values by bits. A successor's strip +/// join reads it: such a lane's cell continues its carried event state verbatim, and every other +/// carried cell is repositioned in its own table at adoption. +#[must_use] +pub fn continued_fader_lanes( + current: &SessionModel, + next: &SessionModel, + strip: &StableId, +) -> [bool; 2] { + if current.automation.is_empty() || next.automation.is_empty() { + return [false; 2]; + } + let (before, after) = ( + fader_automation(current, strip), + fader_automation(next, strip), + ); + [0, 1].map(|lane| { + matches!((before[lane], after[lane]), (Some(before), Some(after)) + if same_segments(before, after)) + }) +} + /// An entry's segments in the evaluator's type, in table order: the one conversion of a stored /// segment, which preparation and the order rule read. #[must_use] diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index daca9b654..b64e0d069 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -46,7 +46,7 @@ mod view; pub use automation::{ CellRule, ControlDiagnostic, FADER_CELL_RULE, automated_cell, automated_fader_lanes, - automation_diagnostics, automation_row_renders, fader_automation, + automation_diagnostics, automation_row_renders, continued_fader_lanes, fader_automation, filter_statics_changed_under_automation, filter_under_automation, rendered_automation_changed, stored_segments, }; diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 79e2d8e0a..f0b2720f6 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1808,18 +1808,21 @@ pub trait GraphPreparedBuiltinBankProcessor: Send + Any { false } /// Copy `predecessor`'s lane `predecessor_lane` -- the whole state of `owner`'s stage: its - /// gains, mutes or matrix and any ramp in flight -- into this bank's lane `lane`, at the swap - /// block (issue #1277 D8). The caller has applied `predecessor`'s pending live values first - /// ([`Self::drain_strip_for_carry`]). `false` (the default) copies nothing. Render-thread code: - /// allocation-free and bounded. + /// gains, mutes or matrix and any ramp in flight, and a fader stage's stored automation event + /// state (#1648 D2) -- into this bank's lane `lane`, at the swap block (issue #1277 D8). The + /// caller has applied `predecessor`'s pending live values first + /// ([`Self::drain_strip_for_carry`]). `continued_cells` is the move's + /// [`GraphStripMove::continued_cells`]. `false` (the default) copies nothing. Render-thread + /// code: allocation-free and bounded. fn adopt_strip_lane( &mut self, owner: BuiltinStripOwner, lane: usize, predecessor: &mut dyn GraphPreparedBuiltinBankProcessor, predecessor_lane: usize, + continued_cells: u8, ) -> bool { - let _ = (owner, lane, predecessor, predecessor_lane); + let _ = (owner, lane, predecessor, predecessor_lane, continued_cells); false } /// The lanes (bit `n` is lane `n`) whose [`Self::begin_block`] drains a live lane, or `0` for @@ -3644,6 +3647,11 @@ pub struct GraphStripMove { pub successor: GraphLaneLocation, /// The lane in the plan the successor displaces. pub predecessor: GraphLaneLocation, + /// The fader stage's automated cells (bit 0 left, bit 1 right) whose stored automation table + /// the successor's model keeps bit for bit from the predecessor's (#1648 D2): such a cell's + /// carried event state continues verbatim, and every other carried cell is repositioned in its + /// own table at adoption. `0` for a matrix move. + pub continued_cells: u8, } /// One strip stage owner's lane in a bound graph plan (issue #1277 D1). diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index b10c0ecec..2829ef0c9 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -5150,7 +5150,13 @@ impl crate::runtime::Runtime { let Some(into) = builtin_processor_mut(chain, slot) else { continue; }; - let adopted = into.adopt_strip_lane(step.owner, lane, from, predecessor_lane); + let adopted = into.adopt_strip_lane( + step.owner, + lane, + from, + predecessor_lane, + step.continued_cells, + ); debug_assert!(adopted, "a checked strip move is adopted"); } } diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index dddba5460..7847b7504 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -4564,6 +4564,69 @@ impl<'a> EffectiveDiff<'a> { ) } + /// The carry retargets of one strip's fader/mute stage at a successor's strip join (#1277 D5): + /// its [`Self::level_records`], plus the release of each lane whose stored fader automation + /// the change removes (#1648 D2, "a cell lost"). Such a lane's carried stage holds the curve's + /// last target, which no model holds, so the static values of the two models cannot show the + /// difference; a `FaderDb` of the lane's effective value in `next` moves it there over the + /// lane's fader length, as a live write does (the stage's live-write rule then holds the + /// batch 13b obligation: inside an unmute ramp the write retargets, while muted the stage + /// remembers it for the unmute). A released lane whose static value changed already has its + /// record. The live classifier never reaches a removal (#1648 D1 rebuilds it), so this is the + /// join's alone. + /// + /// # Errors + /// + /// [`OutOfDomain`] as [`fader_records`]. + pub(crate) fn join_level_records( + &self, + before: usize, + after: usize, + ) -> Result<[Option; 4], OutOfDomain> { + let mut records = self.level_records(before, after)?; + let change = self.change(before, after); + let automated_before = controls::automated_fader_lanes(self.rules.current, change.id); + if automated_before == [false; 2] { + return Ok(records); + } + let automated_after = + controls::automated_fader_lanes(self.rules.next.structure(), change.id); + let mut released = [0, 1].map(|lane| automated_before[lane] && !automated_after[lane]); + for record in records.iter().flatten() { + if let TrackFaderRecord::FaderDb { lanes, .. } = record { + let covered = match lanes { + BuiltinLaneSelector::Left => [true, false], + BuiltinLaneSelector::Right => [false, true], + BuiltinLaneSelector::Both => [true, true], + }; + for (released, covered) in released.iter_mut().zip(covered) { + *released &= !covered; + } + } + } + if released == [false; 2] { + return Ok(records); + } + let lengths = self.rules.lengths(&change); + let db = self.after.strips[after].fader_db; + // The array holds at most one `FaderDb` and one `Mute` per lane, and a released lane has + // no `FaderDb` yet, so a free slot is always there. + let mut free = records.iter_mut().filter(|record| record.is_none()); + for (lanes, lane) in lane_records( + released, + db[0].to_bits() == db[1].to_bits() && lengths.fader[0] == lengths.fader[1], + ) { + if let Some(slot) = free.next() { + *slot = Some(TrackFaderRecord::FaderDb { + lanes, + db: db[lane], + smoothing_samples: lengths.fader[lane], + }); + } + } + Ok(records) + } + #[cfg(feature = "control-provider")] /// The effective gate of route `index`, per source lane, in `[current, next]`. pub(crate) fn gates(&self, index: usize) -> [[bool; 2]; 2] { @@ -5067,6 +5130,10 @@ struct StripJoin<'a> { id: &'a str, fader: FamilyJoin<[Option; 4]>, matrix: FamilyJoin, + /// The fader lanes whose stored automation table both models hold bit for bit (#1648 D2, + /// [`controls::continued_fader_lanes`]), bit 0 left and bit 1 right: the fader move's + /// [`graph::GraphStripMove::continued_cells`]. + continued_cells: u8, } impl StripJoin<'_> { @@ -5153,6 +5220,7 @@ fn join_strips<'a>( id, fader: FamilyJoin::Prepared, matrix: FamilyJoin::Prepared, + continued_cells: 0, }); } continue; @@ -5163,7 +5231,7 @@ fn join_strips<'a>( // A retarget is the record of the same edit, so it carries the length that edit gave its // row (#1394 D6), else the session's. let edit = StripEditRamps::of(edit_ramps, strip.id); - let fader_records = diff.level_records(position, index); + let fader_records = diff.join_level_records(position, index); let matrix_record = matrix_record(before_matrix, strip.matrix_or_pan, ramps, edit); let fader_live = live && fader_records.is_ok(); let matrix_live = live && matrix_record.is_ok(); @@ -5179,7 +5247,14 @@ fn join_strips<'a>( Ok(Some(_)) if !matrix_live => FamilyJoin::Prepared, Ok(Some(record)) => FamilyJoin::Live(record), }; - joins.push(StripJoin { id, fader, matrix }); + let continued = controls::continued_fader_lanes(committed, model, strip.id); + let continued_cells = u8::from(continued[0]) | (u8::from(continued[1]) << 1); + joins.push(StripJoin { + id, + fader, + matrix, + continued_cells, + }); } joins } @@ -5449,6 +5524,11 @@ fn resolve_strip_carry<'a>( owner, successor: own.location, predecessor: before.location, + continued_cells: if owner == Fader { + join.continued_cells + } else { + 0 + }, }); } } diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index 2ecb4514a..82a3e511c 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -1241,21 +1241,23 @@ fn the_scalar_oracle_renders_the_banks_fader_events() { } } -/// #1647 D2 (departure 4): a lane a successor adds starts at its prepared value, the curve's -/// first value, and its first block lands a `Set` when the curve at that block's node time -/// differs. Track 1 gains a fader step from `0` to `-9` dB at timeline 300 in a successor swapped -/// in at block 6 (render sample 768, no seek): from the swap block the output equals, bit for bit, -/// the successor's session rendered fresh from sample 0, whose lane took the jump at 300 (over -/// 240 samples) and holds `-9` dB. +/// #1648 D2, "a cell gained" (superseding #1647b's restart `Set` for a lane whose stage +/// carries): a lane a successor adds to stored fader automation on a carried stage is not set +/// exactly; it jumps from the carried gain to its curve over the fader jump length. Track 1 gains a +/// fader step from `0` to `-9` dB at timeline 300 in a successor swapped in at block 6 (render +/// sample 768, no seek), with a 240-sample jump: the swap block differs from the successor's +/// session rendered fresh from sample 0 (whose lane took its own jump at 300 and stands at `-9` +/// dB), and from the block after the adoption jump completes every block equals it bit for bit. /// -/// Test value: red if a restarted or added lane keeps its prepared value into the successor (it -/// would glide from `0` dB on the first grid event instead of standing at the curve). +/// Test value: red if the gained cell keeps its prepared value (`0` dB, the curve's first value) +/// into the successor, or lands a `Set` instead of the adoption jump. #[cfg(feature = "test-support")] #[test] -fn a_lane_a_successor_adds_starts_at_its_curve() { - use successor::{Feed, SWAP_BLOCK, Session, Successor, reference_run, swapped_run}; +fn a_lane_a_successor_adds_jumps_to_its_curve() { + use successor::{Feed, QUANTUM, SWAP_BLOCK, Session, Successor, reference_run, swapped_run}; let mut base = plain(2, 128); - // A 240-sample jump: the fresh reference's ramp at 300 completes before the swap block. + // A 240-sample jump: the fresh reference's ramp at 300 completes before the swap block, and + // the adoption jump inside the swap block's successor. base.control_smoothing = smoothing(5.0); let mut ride = base.clone(); ride.automation = vec![fader_entry( @@ -1267,30 +1269,42 @@ fn a_lane_a_successor_adds_starts_at_its_curve() { let a = Session::compile(base); let b = Session::compile(ride); let feed = Feed::new("fixture-source", 7); + let settled = SWAP_BLOCK + 240_usize.div_ceil(QUANTUM); + assert!(settled < successor::BLOCKS); for backend in [ #[cfg(target_feature = "avx2")] graph_compiler::Backend::Simd8, graph_compiler::Backend::Simd4, ] { let swapped = swapped_run(&a, &b, &[&feed], &[&feed], backend, Successor::Carry); + assert!( + swapped.restarted.is_empty(), + "{backend:?}: the stage carries" + ); let reference = reference_run(&b, &[&feed], backend); let without = reference_run(&a, &[&feed], backend); - for (block, (swapped, reference)) in swapped.blocks[SWAP_BLOCK..] + assert_eq!( + swapped.blocks[SWAP_BLOCK - 1].bits, + without[SWAP_BLOCK - 1].bits, + "{backend:?}: the predecessor" + ); + assert_ne!( + swapped.blocks[SWAP_BLOCK].bits, reference[SWAP_BLOCK].bits, + "{backend:?}: the swap block ramps, it is not set" + ); + assert_ne!( + swapped.blocks[SWAP_BLOCK].bits, without[SWAP_BLOCK].bits, + "{backend:?}: the swap block moves toward the curve" + ); + for (block, (swapped, reference)) in swapped + .blocks .iter() - .zip(&reference[SWAP_BLOCK..]) + .zip(&reference) .enumerate() + .skip(settled) { - assert_eq!( - swapped.bits, - reference.bits, - "{backend:?}: block {}", - SWAP_BLOCK + block - ); + assert_eq!(swapped.bits, reference.bits, "{backend:?}: block {block}"); } - assert_ne!( - reference[SWAP_BLOCK].bits, without[SWAP_BLOCK].bits, - "the ride is heard" - ); } } diff --git a/crates/host-core/tests/successor_swap.rs b/crates/host-core/tests/successor_swap.rs index e24356d87..899cba243 100644 --- a/crates/host-core/tests/successor_swap.rs +++ b/crates/host-core/tests/successor_swap.rs @@ -10993,3 +10993,109 @@ mod spectrum_carry { ); } } + +// Issue #1648b: a fader stage's stored automation event state carries across a swap (D2). +mod fader_automation_carry { + use super::*; + use session::{ + Automation, AutomationOffset, AutomationSegment, AutomationShape, ControlAddress, Owner, + ParameterChannel, ParameterUnit, Section, + }; + + /// `model` with a linear fader ride on `track`'s `lane`, `0` dB at timeline 0 to `end_db` at + /// 3,000: it moves across [`SWAP_BLOCK`]. + fn ride(model: &mut SessionModel, track: &str, lane: ParameterChannel, end_db: f32) { + model.automation.push(Automation { + id: id(&format!("{track}-ride")), + target: ControlAddress { + owner: Owner::Strip(id(track)), + section: Section::Builtins, + control: 5, + lane, + }, + segments: vec![AutomationSegment { + shape: AutomationShape::Linear, + start_sample: 0, + end_sample: 3_000, + start_value: 0.0, + end_value: end_db, + unit: ParameterUnit::Db, + }], + offset: AutomationOffset::default(), + }); + model.automation.sort_by(|a, b| a.id.cmp(&b.id)); + } + + /// Gate 6. A live-controlled swap that carries a changed ride on `eq0`, a cell `eq1` gains + /// on its left lane and a cell `eq1` loses on its right lane (the release record) makes no + /// allocator call over the hand-over and the successor's first block, at every width. The + /// same swap with the ride kept and a muted track added renders every block bit-identical to + /// the uninterrupted predecessor. + /// + /// Test value: red if the carry of cell event state, the adoption jump or the release + /// allocates or frees on the render thread, or if a carried cell with an unchanged table does + /// not continue its events (the lane would restart or jump). + #[test] + fn carrying_fader_automation_allocates_and_frees_nothing() { + use bench_support::alloc::{Mode, assert_installed, mode, set_mode}; + struct RestoreMode(Mode); + impl Drop for RestoreMode { + fn drop(&mut self) { + set_mode(self.0); + } + } + assert_installed(); + let _restore = RestoreMode(mode()); + set_mode(Mode::Count); + engine::realtime::audit::warm_up(); + let mut a_model = session_a(); + ride(&mut a_model, "eq0", ParameterChannel::Both, -12.0); + ride(&mut a_model, "eq1", ParameterChannel::Right, -6.0); + let mut changed = session_a(); + ride(&mut changed, "eq0", ParameterChannel::Both, -24.0); + ride(&mut changed, "eq1", ParameterChannel::Left, -9.0); + let mut kept = a_model.clone(); + add_track(&mut kept, "eq0", MUTED_TRACK, SOURCE, true); + let a = Session::compile(a_model); + let (changed, kept) = (Session::compile(changed), Session::compile(kept)); + let feed = Feed::new(SOURCE, 1); + for backend in backends() { + let run = direct_run( + &a, + Some((&changed, &a.model)), + &feed, + QUANTUM, + &[], + backend, + false, + ); + assert_eq!(run.carry, CarryOutcome::Carried, "{backend:?}"); + assert_eq!( + run.swap_allocator, + ((0, 0, 0), (0, 0)), + "{backend:?}: the hand-over and the successor's first block" + ); + let uninterrupted = direct_run(&a, None, &feed, QUANTUM, &[], backend, false); + assert_ne!( + run.blocks[SWAP_BLOCK..], + uninterrupted.blocks[SWAP_BLOCK..], + "{backend:?}: the changed rides are heard" + ); + let run = direct_run( + &a, + Some((&kept, &a.model)), + &feed, + QUANTUM, + &[], + backend, + false, + ); + assert_eq!(run.carry, CarryOutcome::Carried, "{backend:?}"); + assert_eq!( + first_direct_difference(&run, &uninterrupted), + None, + "{backend:?}: a kept ride continues" + ); + } + } +} diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index cd55beb02..d322761be 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -2765,6 +2765,9 @@ fn both_hosts_refuse_a_source_of_more_than_two_channels_alike() { /// an entry on a row that does not render yet (the mute, row 6) commits `model_only` on both. /// Both hosts call the one shared classifier, so neither has its own rule for automation edits. /// +/// A changed ride and its removal then rebuild alike too, and both hosts render the same bits +/// through the carried cell's adoption jump and the release (#1648b). +/// /// Test value: red if either host acks a fader automation edit as `model_only` (an edit that is /// never heard), if the two hosts answer it differently, or if the browser's rebuild renders other /// bits than the C ABI's. @@ -2797,6 +2800,18 @@ fn a_fader_automation_edit_rebuilds_alike_on_both_hosts() { unit: session::ParameterUnit::Db, }, ); + let changed = entry( + "t1-fader-ride", + 5, + session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: 9_600, + start_value: 0.0, + end_value: -18.0, + unit: session::ParameterUnit::Db, + }, + ); let mute = entry( "t1-mute-ride", 6, @@ -2840,6 +2855,32 @@ fn a_fader_automation_edit_rebuilds_alike_on_both_hosts() { "{rate} Hz: a mute entry is model-only" ); same_blocks(&mut browser, &mut c_abi, 8..10, "after the mute entry"); + // #1648b (gate 2's browser leg): a changed ride and a removed ride rebuild alike, and the + // carried cell's adoption jump and the release render the same bits on both hosts. + for (step, edit, blocks) in [ + (3, changed.clone(), 10..18), + ( + 4, + SessionEdit::RemoveAutomation { + automation_id: id("t1-fader-ride"), + }, + 18..26, + ), + ] { + let (b, b_reply) = browser.transaction_reply(std::slice::from_ref(&edit)); + let (c, c_reply) = c_abi.transaction(std::slice::from_ref(&edit)); + assert_eq!(b, c, "{rate} Hz: one verdict for edit {step}"); + assert_eq!(b_reply, c_reply, "{rate} Hz: one reply for edit {step}"); + assert_eq!( + b, + Verdict::Applied { + path: EditPath::Rebuild, + revision: model.revision + step + }, + "{rate} Hz: edit {step} rebuilds" + ); + same_blocks(&mut browser, &mut c_abi, blocks, "across the carried swap"); + } } } From 275ff76e83b72b99241531ae153cea572a7d9ede Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 20:20:33 +0000 Subject: [PATCH 19/35] Keep the hostile-word probes honest in release; guard prepare_session_builtins U1 fold verification MINOR 1: the three canonical-environment tests (host-core, C ABI, browser) were falsely red under --release because LLVM folded their vacuity probes; black_box the probe inputs and outputs. NIT 3: the public prepare_session_builtins composes controls::effective inside CanonicalFpEnv. NIT 1: comment the scalar oracle's defensive guard. Co-Authored-By: Claude Opus 5.5 --- crates/builtins-compiler/src/lib.rs | 13 +++++++--- crates/capi/src/runtime/live_tests.rs | 9 ++++++- .../tests/stored_fader_automation.rs | 25 +++++++++++++------ hosts/host-web/src/tests.rs | 9 ++++++- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index e317062a2..a67789b9a 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -5163,6 +5163,7 @@ impl PreparedBuiltinsSession { report: R, ) -> PreparedBuiltinsGraphArtifact { // #1646 D2: the scalar oracle rebuilds the ramped fader from the prepared parameters here. + // Defensive for direct oracle callers; `into_graph_artifact_with_banks` already holds it. let _canonical = lane::CanonicalFpEnv::enter(); let mut processors = core::mem::take(&mut self.processors); processors.append(&mut self.strip_bindings()); @@ -6366,10 +6367,14 @@ pub fn prepare_session_builtins( requests: &[MeterRequest], caps: BuiltinCompileCaps, ) -> Result { - let effective = controls::effective( - session.normalized_model(), - &controls::MonitorOverlay::default(), - ); + // #1646 D2: the effective values are composed in the canonical floating-point environment. + let effective = { + let _canonical = lane::CanonicalFpEnv::enter(); + controls::effective( + session.normalized_model(), + &controls::MonitorOverlay::default(), + ) + }; prepare_session_builtins_with_live_controls(session, &effective, requests, &[], caps) } diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index dee97e15a..df10559cb 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -8121,7 +8121,14 @@ mod set_control { /// The fixture is not vacuous: the hostile word composes the pair to another `f32`, and /// that `f32` converts to another gain. fn assert_the_hostile_word_rounds_the_composition() { - let compose = || session::vca_effective_db(MEMBER_DB, [OFFSET_DB, 0.0]); + // `black_box` on the inputs and outputs: under `--release` LLVM otherwise folds the + // composition at compile time, whatever the thread's word. + let compose = || { + core::hint::black_box(session::vca_effective_db( + core::hint::black_box(MEMBER_DB), + [core::hint::black_box(OFFSET_DB), 0.0], + )) + }; let canonical = compose(); let rounded = under_hostile_fp_env(compose); assert_ne!( diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index 6e8ce5227..c52d925af 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -1785,10 +1785,17 @@ mod canonical_preparation { decibels .iter() .filter(|&&db| { - let gain = builtins::checked_fader_gain(db).expect("in domain"); + // `black_box` on the input and both outputs: under `--release` LLVM + // otherwise folds the two conversions across the guard. + let gain = core::hint::black_box( + builtins::checked_fader_gain(core::hint::black_box(db)).expect("in domain"), + ); let pinned = { let _pin = lane::CanonicalFpEnv::enter(); - builtins::checked_fader_gain(db).expect("in domain") + core::hint::black_box( + builtins::checked_fader_gain(core::hint::black_box(db)) + .expect("in domain"), + ) }; gain.to_bits() != pinned.to_bits() }) @@ -1804,10 +1811,11 @@ mod canonical_preparation { route_decibels .iter() .filter(|&&db| { - let gain = math::db_to_gain_f32(core::hint::black_box(db)); + let gain = + core::hint::black_box(math::db_to_gain_f32(core::hint::black_box(db))); let pinned = { let _pin = lane::CanonicalFpEnv::enter(); - math::db_to_gain_f32(core::hint::black_box(db)) + core::hint::black_box(math::db_to_gain_f32(core::hint::black_box(db))) }; gain.to_bits() != pinned.to_bits() }) @@ -1817,9 +1825,12 @@ mod canonical_preparation { routes_moved > 0, "the gate is vacuous: no fixture route gain converts differently under the hostile word" ); - let offset = f64::from(VCA_DB); - let composed = session::vca_compose_db(VCA_MEMBER_DB, offset); - let rounded = under_hostile_fp_env(|| session::vca_compose_db(VCA_MEMBER_DB, offset)); + let offset = core::hint::black_box(f64::from(VCA_DB)); + let member = core::hint::black_box(VCA_MEMBER_DB); + let composed = core::hint::black_box(session::vca_compose_db(member, offset)); + let rounded = core::hint::black_box(under_hostile_fp_env(|| { + session::vca_compose_db(core::hint::black_box(member), core::hint::black_box(offset)) + })); assert_ne!( builtins::checked_fader_gain(composed).map(f32::to_bits), builtins::checked_fader_gain(rounded).map(f32::to_bits), diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index ca5b5148d..8b2a00810 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -10386,7 +10386,14 @@ mod canonical_live { /// the caller's environment: remove the guard in the core's `SessionState::command`. #[test] fn a_live_fader_and_vca_move_under_a_hostile_caller_word_render_the_canonical_bits() { - let compose = || session::vca_effective_db(MEMBER_DB, [OFFSET_DB]); + // `black_box` on the inputs and outputs: under `--release` LLVM otherwise folds the + // composition at compile time, whatever the thread's word. + let compose = || { + core::hint::black_box(session::vca_effective_db( + core::hint::black_box(MEMBER_DB), + [core::hint::black_box(OFFSET_DB)], + )) + }; let rounded = under_hostile_fp_env(compose); assert_ne!( builtins::checked_fader_gain(compose()).map(f32::to_bits), From 3cf702b9e2b920847664bb56863cd5962afe3c52 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 20:27:11 +0000 Subject: [PATCH 20/35] Store a hand move on an automated fader as a dB offset (#1650b) On a lane a rendered entry drives, SET_CONTROL fader_db reads and writes the entry's offset (D3); S = own offset + reaching VCA offsets (D6) reaches render through the offsets cell with the Fader-row length. R17-1: the driving entries are owners of the in-place edit, so the fast and full paths give the same reply and bits on both hosts (equivalence gate). Fallback change = Structure. The list's automated flag is a descriptor field (ControlEntry field 35; SDK decode). COMPLETE_SCHEMA_HASH 0x5eb9ad2ed137f3be -> 0x5cab2cf3b4e4767f (field 35). pcm_digest unchanged; realtime policy 160. Co-Authored-By: Claude Opus 5.5 --- ...-automated-fader-or-trim-by-a-hand-move.md | 111 +++++ crates/capi/include/miso_engine_v1.h | 4 +- crates/capi/src/runtime/live_tests.rs | 321 +++++++++++++- crates/conformance/src/protocol_corpus.rs | 8 +- .../tests/vca_automation_realtime.rs | 86 +++- crates/controls/src/builtin_rows.rs | 84 +++- crates/controls/src/effective.rs | 91 +++- crates/controls/src/mask.rs | 44 +- crates/controls/src/owner_edits.rs | 34 +- crates/controls/src/registry.rs | 27 ++ crates/controls/src/strip.rs | 19 +- crates/controls/src/view.rs | 34 +- crates/controls/tests/composition.rs | 4 + crates/controls/tests/effective.rs | 1 + crates/host-core/src/control_provider.rs | 113 +++++ crates/host-core/src/live_delta.rs | 2 + crates/host-core/src/prepare.rs | 16 +- crates/host-core/tests/live_delta.rs | 136 ++++++ .../tests/stored_fader_automation.rs | 9 +- crates/protocol/src/controller/tests.rs | 1 + crates/protocol/src/message_wire.rs | 16 +- crates/protocol/src/message_wire/tests.rs | 6 + crates/protocol/src/schema.rs | 3 + crates/session/src/in_place.rs | 29 +- crates/session/src/validate.rs | 5 + crates/session/tests/in_place.rs | 1 + docs/CONTROL_PROTOCOL_CONFORMANCE.md | 2 +- docs/CONTROL_PROTOCOL_REGISTRY.md | 4 +- docs/CONTROL_PROTOCOL_SEMANTICS.md | 2 +- docs/C_ABI_V1_QUALIFICATION.md | 6 + docs/SESSION_SCHEMA_V1.md | 18 +- fuzz/corpus/complete-schema-manifest.md | 2 +- hosts/host-web/src/tests.rs | 18 +- hosts/host-web/tests/control_parity.rs | 411 ++++++++++++++++++ scripts/check-protocol-wasm-parity.sh | 4 +- sdk/src/core/control-list.ts | 9 +- 36 files changed, 1612 insertions(+), 69 deletions(-) diff --git a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md index bd0cef981..d51bbf3bd 100644 --- a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md +++ b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md @@ -505,3 +505,114 @@ control diagnostic code, not a `COMMAND_REASON`), `check-builtins-fixtures.sh`, built AudioWorklet module; `audit capi` (`pcm_digest` `bffc21e857b5e134`, 0 violations), `audit protocol` ok; fmt, clippy `-D warnings` (all features) and the no-feature `cargo check` on the touched crates. + +## Decision record: #1650b (batch 17) + +Implemented on `codex/controls-batch-17w` over `5e82ba245`, uncommitted, for the coordinator. +Fader only (R17-6); R17-1 = A. + +**What shipped.** + +- **D3, the binding (`controls`).** `builtin_rows::read` takes the strip's `FaderOffsets` + (`[Option; 2]`, the offset of the rendered entry that drives each lane, from + `fader_automation`): `fader_db` on an automated lane reads the offset, else the static fader. + `registry::write` sends a `fader_db` write on a driven lane to `builtin_rows::write_fader_offset` + (the driving entry's lane offset) and only the undriven sides to the static field. `strip::resolve` + reads the entries for row 5 only; `owner_rows_in` reads them through + `ModelView::fader_automation` (new: the committed table's driving entries, each replaced by the + edited clone when the view holds one). So `get`, `set`, `list`, `changed_controls`, + `changed_rows` and the catalog all see the offset as the row's value. +- **R17-1 = A, the in-place owner.** `OwnerEdits::set` of a strip's `fader_db` clones the rendered + entries that drive that strip's fader into the owners model (ID order; a scan of the committed + table, early out when empty); `finish` gives their slots. `session::OwnerSlots` gains + `automation`, `OwnerRef` gains `Automation` (in `in_owners`/`in_committed`, canonical bytes, + `owners_growth` by segment count, `owners_values_valid`: finite offsets), and + `CompiledSession::swap_owners` swaps entries. `ModelView::to_model` installs edited entries. + `protocol`'s `prepare_in_place` needed no edit: it reads the slots generically. The fast + classifier (`classify_rows`) reads the entries through the same view: `changed_rows` on the + owners model and `LocalInputs::fader_offset` through `ModelView::fader_automation`. +- **D6, `S` (`controls::effective`).** `EffectiveInputs::fader_offset` (new; `ModelInputs` from + one pass over the table, `LocalInputs` through the view) and `EffectiveStrip::fader_offset` + (new). `offsets_sum` is the lane's hand offset first, then the reaching VCA offsets in + ascending VCA ID, `f64`; `fader_db` (the static composition) is unchanged. Preparation seeds the + cell from it (#1646), the classifier writes it (#1649's `level_records_between`). No render file + changed. +- **D5, the classifier.** `mask_values` copies each rendered entry's offset (same ID and target) + and keeps `into`'s static fader dB on lanes `into` automates (departure 2: a static fallback + change on an automated lane is step 1 `Structure`). A hand move or an offset-only + `UpsertAutomation` is a changed `fader_db` row, so step 4/6 lowers one offsets-cell write per + moved lane. `LevelRules::lengths` (host-core `prepare.rs`) takes row `Fader` with the edit's + ramp when the lane's hand offset changed (`StripChange::offset_before/after`, from the effective + strips); before, such a lane fell to row `VcaOffset`. +- **D4, the list flag.** `controls::ControlRow::automated` (set on `fader_db` rows of driven lanes, + `push_control_automated`); host-core's catalog `Shape` carries it, so it is a descriptor field + and a change advances the structure epoch. Wire: `ControlEntry` field 35 `automated BOOL`, + written on every control row, refused on a header, and a row is automated only if it is + automatable. SDK: `ControlRow.automated` decoded from field 35 (no row table moved, so no + regeneration). `docs/CONTROL_PROTOCOL_REGISTRY.md` field 35. + +**Departures and readings.** + +- *`prepare.rs` edit (outside the path list).* The `Fader` row length for a hand offset change + lives in `LevelRules::lengths`; without it the move ramps over the VCA row's length, which D5 + forbids. Two fields on `StripChange` and one condition. +- *Mechanical, outside the list:* `crates/session/src/validate.rs` (the `OwnerRef::Automation` + arm), `crates/session/tests/in_place.rs` (slot literal), `crates/host-core/tests/stored_fader_automation.rs` + (the retained-heap charge now subtracts the catalog's own delta: the automated left row is a + descriptor of its own, 235 bytes there, charged by the catalog's resources), `crates/protocol/src/{controller,message_wire}/tests.rs` + (row literals; one new invalid case), the re-pin mirrors, `hosts/host-web/src/tests.rs` (TBD48), + and the docs that described an automated fader (`docs/SESSION_SCHEMA_V1.md` offset paragraph + now says it is heard and records departure 2; `docs/CONTROL_PROTOCOL_SEMANTICS.md`, + `docs/C_ABI_V1_QUALIFICATION.md`, the C header's live-edits comment; `check-capi-abi.sh` ok). +- *`S` of a `+0.0` offset with a `-0.0` VCA sum* is `+0.0` instead of `-0.0`; the gain bits are the + same (D6), and gate 3 holds it. +- *Cost.* `owner_rows_in` and `mask_values` read the table per strip / once; a list of a session + with many strips and many entries scans the table per strip (O(strips x entries) comparisons). + Not optimised here (successor below). + +**Re-pin.** `COMPLETE_SCHEMA_HASH` `0x5eb9_ad2e_d137_f3be` -> `0x5cab_2cf3_b4e4_767f`, reason: +#1650b adds `ControlEntry` field 35 `automated` on every control row, and the corpus list page's +live `ratio` row is automated; 46 frames still. Mirrors: `scripts/check-protocol-wasm-parity.sh`, +`fuzz/corpus/complete-schema-manifest.md`, `docs/CONTROL_PROTOCOL_CONFORMANCE.md`. The capi +`pcm_digest` is unchanged (`bffc21e857b5e134`). + +**Gates and tests (all pass, x86-64 AVX2).** + +- Gate 2 + R17-1 equivalence, both hosts: `a_hand_move_on_an_automated_fader_is_an_offset_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`, test-support, four rates): `live`, one revision, the + document stores `+3` left and keeps the static fader, from the jump on the bits of a boot of that + document, list left `+3`/right `0` both `automated` at the same epoch; the in-place leg commits in + place on both hosts (`(2, 0)`), and the forced full leg gives the same reply, document, list and + bits. C ABI: `set_control::automated_hand_move::a_hand_move_on_an_automated_fader_is_live_and_renders_the_stored_offset` + (one offsets-cell write on the left lane, none on the right, 0 render allocations, in place vs + full path equal). **Red on revert** (the `take_fader_automation` call in `OwnerEdits::set` + removed, checked once): both answer `ModelOnly` on the in-place leg, and the classifier gate + fails. +- Classifier (D5, R17-1 on the fast path): `a_hand_move_on_an_automated_fader_writes_the_lanes_offsets_cell` + (`crates/host-core/tests/live_delta.rs`): `S = 3 + (-2)` with the edit's 256 samples, no + `FaderDb`; offset-only upsert live; a static fallback change `Structure`; fast path whole and + over the in-place `ModelView` equal the full classifier. +- Gate 3: `set_control::automated_hand_move::a_zero_offset_renders_the_static_fader` (offsets + `0` and `-0.0`, with and without a reaching VCA). +- Gate 4: `re_authoring_keeps_the_hand_offset_and_removal_ends_it_alike_on_both_hosts` (both hosts, + four rates) and the C ABI's `re_authoring_keeps_the_hand_offset_and_removal_ends_it`. +- Gate 5: `an_automated_fader_lane_lists_its_offset_and_the_flag_is_a_descriptor` (host-core + catalog) and the stale-epoch legs of the gate 4 parity test; wire rule in + `message_wire/tests.rs` ("automated row that is not automatable"). +- Gate 6: `a_hand_move_on_an_automated_fader_renders_allocation_free` + (`crates/control-plane/tests/vca_automation_realtime.rs`). +- TBD48: both `canonical_live` modules gain "a hand move on an automated lane". + +**Checks run.** `cargo test --locked -p session -p protocol -p controls -p control-plane -p conformance` +and `-p host-core --all-features` (953 passed); `-p capi` (206); `-p host-web --features +host-web/test-support --lib` (148) and `--test control_parity` (27); `check-sdk-types.sh`, +`check-sdk-generated.sh`, `check-sdk-headless.sh` (artifact built locally, 569 pass); +`check-protocol-wasm-parity.sh` and `--self-test`; `check-command-reason-vocabulary.py`; +`realtime-policy` ok (160 regions, unchanged); `check-capi-abi.sh`; `check-workspace-policy.sh`; +`audit capi` (`pcm_digest` `bffc21e857b5e134`, 0 violations, 0 allocations) and `audit protocol` +ok; fmt; clippy `-D warnings` (touched crates, all targets, all features); no-feature `cargo check +--all-targets` on the touched crates. + +**Successor work.** (1) Index the rendered fader entries by strip once per list/catalog build +instead of a scan per strip. (2) #1658 and #1654 extend `FaderOffsets`, `takes_offset` and the +`automated` flag to the trim and the send gain. diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index 1a5bc1079..b29697944 100644 --- a/crates/capi/include/miso_engine_v1.h +++ b/crates/capi/include/miso_engine_v1.h @@ -47,7 +47,9 @@ extern "C" { * commits with no replacement and no live value. A stored fader automation edit (an entry on a * strip's fader_db added, removed, retargeted or given other segments) rebuilds the plan, and * the successor carries every owner; a static fader value of a lane the session automates is that - * lane's fallback and gives no live value; the other rows stay model-only until they render. + * lane's fallback and gives no live value, and a SET_CONTROL of that lane's fader_db writes the + * entry's hand offset instead, a live value heard on top of the curve (#1650); the other rows stay + * model-only until they render. * Any other change takes the replacement path exactly as before: every submix strip * value (its fader, its effects), any edit while the session declares a VCA, the mute of a track * that a follows_mute send reads, a fader outside its domain or effect parameters that diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index f27612b31..22de27007 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -8197,8 +8197,9 @@ mod set_control { } /// TBD48 D2 (C ABI): a live fader move on a VCA member, a live VCA offset move over it, - /// and (#1649) that VCA move over a member whose fader is automated, which writes its - /// offsets cell, each made while the caller's thread runs FTZ, DAZ and round toward zero, render + /// (#1649) that VCA move over a member whose fader is automated, which writes its + /// offsets cell, and (#1650b) a hand move on that automated fader, which writes it too, + /// each made while the caller's thread runs FTZ, DAZ and round toward zero, render /// from the end of their ramp the bits of the same committed session prepared on a /// canonical thread, and the catalog's derived fader row holds the canonical composition. /// The pair's composition rounds to another `f32`, and another gain, under that word @@ -8225,6 +8226,13 @@ mod set_control { &automated_document(MEMBER_DB, 0.0), set(vca_row("inner", VCA_OFFSET_DB, Lane::Left), OFFSET_DB, None), ); + // #1650b: a hand move on the automated lane writes its offsets cell with `S` = the + // stored offset and the VCA's. + live_move_under_hostile_word( + "a hand move on an automated lane", + &automated_document(MEMBER_DB, OFFSET_DB), + set(builtin("eq0", FADER_DB, Lane::Left), MEMBER_DB, None), + ); } } @@ -8234,7 +8242,7 @@ mod set_control { /// The fader ride of `eq0` (both lanes): linear from 0 dB at sample 0 to -12 dB at the /// source's end, so every grid point moves the lane. - fn ride() -> session::Automation { + pub(super) fn ride() -> session::Automation { session::Automation { id: id("eq0-fader-ride"), target: session::ControlAddress { @@ -8257,7 +8265,7 @@ mod set_control { /// The ten-track session at 48 kHz with `eq0` riding its fader, VCA `v` holding `eq0` and /// `eq1` (which has no automation), and `change` applied. - fn document(change: impl FnOnce(&mut SessionModel)) -> String { + pub(super) fn document(change: impl FnOnce(&mut SessionModel)) -> String { with_model(&long_session(10, 48_000), |model| { model.automation.push(ride()); model.vcas.push(vca_of("v", &["eq0", "eq1"])); @@ -8275,7 +8283,7 @@ mod set_control { /// `(eq0's offsets-cell writes [left, right], eq1's fader/mute cell writes)` in the current /// epoch. - fn writes(rig: &Rig) -> ([Option; 2], [u64; 4]) { + pub(super) fn writes(rig: &Rig) -> ([Option; 2], [u64; 4]) { let snapshot = crate::ffi::test_transaction_snapshot(rig.session); let epoch = snapshot.provider_epoch; let offsets = snapshot @@ -8366,7 +8374,7 @@ mod set_control { /// The session's fader length, in samples: the `VcaOffset` row's length of a VCA edit with /// no length of its own. - fn fader_length(document: &str) -> u32 { + pub(super) fn fader_length(document: &str) -> u32 { parse_session_json(document) .expect("model") .control_smoothing_samples() @@ -8484,6 +8492,307 @@ mod set_control { } } } + + /// #1650b: a hand move on a fader that stored automation drives, on the C ABI. + mod automated_hand_move { + use super::automated_vca::{document, fader_length, ride, writes}; + use super::*; + + /// The rows of `eq0`'s `fader_db` in the list: per lane, the bits of the value and + /// `automated`. + fn fader_rows(rig: &mut Rig) -> Vec<(Option, u32, bool)> { + let (list, ..) = control_list_all_c(rig.session, &mut rig.request, 256); + list.iter() + .filter(|entry| entry.address.owner_id.as_deref() == Some("eq0")) + .filter_map(|entry| { + control_of(entry) + .filter(|row| row.name == "fader_db") + .map(|row| (entry.address.lane, row.value.to_bits(), row.automated)) + }) + .collect() + } + + /// What one leg of gate 2 gives: the reply, the committed document, the offsets-cell writes + /// the move made and every block from the edit on. + struct Leg { + reply: Vec, + committed: String, + offsets: [Option; 2], + blocks: Vec>, + } + + /// `edits` on a rig on `before` after four blocks: `live`, one revision on the same plan + /// with no candidate; then `blocks` blocks, none of which allocates or frees. + fn leg(label: &str, before: &str, edits: &[SessionEdit], blocks: u64) -> Leg { + let mut rig = Rig::new(before); + for _ in 0..4 { + rig.step(); + } + let (revision, _, epoch, pending) = rig.summary(); + let (offsets_before, _) = writes(&rig); + let bytes = rig.transaction(edits); + let (result, reply) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK, "{label}"); + assert_eq!(edit_path_of(&reply), protocol::EditPath::Live, "{label}"); + assert_eq!( + (rig.summary().0, rig.summary().2, rig.summary().3), + (revision + 1, epoch, pending), + "{label}: one revision, the same plan, no candidate" + ); + let (offsets_after, _) = writes(&rig); + let offsets = [0, 1].map(|lane| { + offsets_after[lane] + .zip(offsets_before[lane]) + .map(|(after, before)| after - before) + }); + let committed = rig.snapshot(); + let mut out = Vec::new(); + for _ in 0..blocks { + let (pcm, used) = rig.step_counted(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "{label}: a render block allocated or freed" + ); + out.push(pcm); + } + Leg { + reply, + committed, + offsets, + blocks: out, + } + } + + /// #1650b gate 2 (C ABI) and root's ruling R17-1. `eq0` rides its fader on both lanes + /// under VCA `v`. `SET_CONTROL fader_db = +3` on its left lane is `live`, one revision on + /// the same plan, and writes the left lane's offsets cell once and the right lane's not at + /// all; the committed document stores `+3` as the ride's left offset and keeps the static + /// fader; from the end of the jump and the first grid ramp after it every block equals a + /// fresh plan of that document, and none allocates. The list reads `+3` on the left row + /// and `0` on the right, both `automated`. The same transaction prepared by the full path + /// gives the same reply, document, cell writes and bits; the in-place leg commits in place. + /// + /// Test value: red if the move writes the static fallback (the curve overrides it, so the + /// window differs from the fresh plan), rebuilds, reaches the right lane, or if the + /// in-place path answers otherwise than the full path (an in-place owners model with no + /// automation entry reads the lane as not automated: `model_only`, checked once). + #[test] + fn a_hand_move_on_an_automated_fader_is_live_and_renders_the_stored_offset() { + bench_support::alloc::assert_installed(); + let before = document(|_| {}); + let edits = [set(builtin("eq0", FADER_DB, Lane::Left), 3.0, None)]; + let probe = Rig::new(&before); + let k = window(probe.latency(), fader_length(&before) + 128, probe.quantum); + drop(probe); + + control_plane::test_take_in_place_counts(); + let in_place = leg("in place", &before, &edits, k + 4); + assert_eq!( + control_plane::test_take_in_place_counts(), + (1, 0), + "the hand move commits in place" + ); + assert_eq!( + in_place.offsets, + [Some(1), Some(0)], + "the left lane's cell only" + ); + let model = parse_session_json(&in_place.committed).expect("model"); + let plain = parse_session_json(&before).expect("model"); + let ride = model + .automation + .iter() + .find(|entry| entry.id == ride().id) + .expect("the ride"); + assert_eq!( + ( + ride.offset.left_db.to_bits(), + ride.offset.right_db.to_bits() + ), + (3.0_f32.to_bits(), 0.0_f32.to_bits()), + "the ride stores the move on its left lane" + ); + assert_eq!( + model.tracks[0].fader, plain.tracks[0].fader, + "the static fader stays" + ); + let reference = Reference::run(&in_place.committed, 4 + k + 4); + let unedited = Reference::run(&before, 4 + k + 4); + let mut moved = false; + for (offset, pcm) in in_place.blocks.iter().enumerate().skip(k as usize) { + let block = 4 + offset; + assert_eq!( + bits(pcm), + bits(&reference[block]), + "block {block} renders the stored offset's plan" + ); + moved |= bits(pcm) != bits(&unedited[block]); + } + assert!(moved, "the offset is heard"); + let mut rig = Rig::new(&in_place.committed); + assert_eq!( + fader_rows(&mut rig), + vec![ + ( + Some(protocol::ParameterChannel::Left), + 3.0_f32.to_bits(), + true + ), + ( + Some(protocol::ParameterChannel::Right), + 0.0_f32.to_bits(), + true + ), + ], + "the list reads the offset back" + ); + + control_plane::test_force_full_preparation(true); + let full = leg("full path", &before, &edits, k + 4); + control_plane::test_force_full_preparation(false); + assert_eq!(control_plane::test_take_in_place_counts().0, 0); + assert_eq!(full.reply, in_place.reply, "one reply on both paths"); + assert_eq!(full.committed, in_place.committed, "one document"); + assert_eq!(full.offsets, in_place.offsets, "one cell write"); + assert_eq!( + full.blocks.iter().map(|pcm| bits(pcm)).collect::>(), + in_place + .blocks + .iter() + .map(|pcm| bits(pcm)) + .collect::>(), + "the same bits on both paths" + ); + } + + /// #1650b gate 3. A flat ride at -7.25 dB with offset `0` renders the bits of a static + /// -7.25 dB fader (#1646 gate 1's twin), with no VCA and under a reaching VCA at -2.5 dB; + /// so does an offset of `-0.0`. + /// + /// Test value: red if the offset term changes the gain bits of a zero offset (a `+0.0` + /// seed that moves `S` off the VCA sum, or a `-0.0` that does). + #[test] + fn a_zero_offset_renders_the_static_fader() { + let flat = |offset: f32, vca: Option| { + with_model(&long_session(10, 48_000), |model| { + let mut entry = ride(); + entry.segments[0].shape = session::AutomationShape::Step; + entry.segments[0].start_value = -7.25; + entry.segments[0].end_value = -7.25; + entry.offset = session::AutomationOffset { + left_db: offset, + right_db: offset, + }; + model.automation.push(entry); + if let Some(db) = vca { + let mut v = vca_of("v", &["eq0"]); + v.fader.left_db = db; + v.fader.right_db = db; + model.vcas.push(v); + } + }) + }; + let fixed = |vca: Option| { + with_model(&long_session(10, 48_000), |model| { + model.tracks[0].fader.left_db = -7.25; + model.tracks[0].fader.right_db = -7.25; + if let Some(db) = vca { + let mut v = vca_of("v", &["eq0"]); + v.fader.left_db = db; + v.fader.right_db = db; + model.vcas.push(v); + } + }) + }; + for vca in [None, Some(-2.5)] { + let reference = Reference::run(&fixed(vca), 24); + for offset in [0.0, -0.0] { + let rendered = Reference::run(&flat(offset, vca), 24); + assert!( + rendered.iter().flatten().any(|sample| *sample != 0.0), + "signal" + ); + for (block, (left, right)) in rendered.iter().zip(&reference).enumerate() { + assert_eq!( + bits(left), + bits(right), + "VCA {vca:?}, offset {offset:?}: block {block}" + ); + } + } + } + } + + /// #1650b gate 4 (C ABI). On a ride that stores a +3 dB left offset, `SetAutomationSegments` + /// rebuilds and keeps the offset (the list still reads +3, `automated`); `RemoveAutomation` + /// rebuilds and removes it, and the lanes list their static fader, not `automated`. + /// + /// Test value: red if a curve edit drops the hand offset, or a removal keeps it. + #[test] + fn re_authoring_keeps_the_hand_offset_and_removal_ends_it() { + let before = document(|model| model.automation[0].offset.left_db = 3.0); + let plain = parse_session_json(&before).expect("model"); + let mut rig = Rig::new(&before); + rig.step(); + let offset_rows = vec![ + ( + Some(protocol::ParameterChannel::Left), + 3.0_f32.to_bits(), + true, + ), + ( + Some(protocol::ParameterChannel::Right), + 0.0_f32.to_bits(), + true, + ), + ]; + assert_eq!(fader_rows(&mut rig), offset_rows); + let mut segments = ride().segments; + segments[0].start_value = -9.0; + let bytes = rig.transaction(&[SessionEdit::SetAutomationSegments { + automation_id: ride().id, + segments, + }]); + let (result, reply) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK); + assert_eq!(edit_path_of(&reply), protocol::EditPath::Rebuild); + rig.step(); + rig.step(); + assert_eq!(fader_rows(&mut rig), offset_rows, "the curve edit keeps it"); + let bytes = rig.transaction(&[SessionEdit::RemoveAutomation { + automation_id: ride().id, + }]); + let (result, reply) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK); + assert_eq!(edit_path_of(&reply), protocol::EditPath::Rebuild); + rig.step(); + rig.step(); + let fader = &plain.tracks[0].fader; + assert_eq!( + fader_rows(&mut rig), + vec![ + ( + Some(protocol::ParameterChannel::Left), + fader.left_db.to_bits(), + false + ), + ( + Some(protocol::ParameterChannel::Right), + fader.right_db.to_bits(), + false + ), + ], + "the removal returns the lanes to their static fader" + ); + assert!( + parse_session_json(&rig.snapshot()) + .expect("model") + .automation + .is_empty() + ); + } + } } // --------------------------------------------------------------------------------------------- diff --git a/crates/conformance/src/protocol_corpus.rs b/crates/conformance/src/protocol_corpus.rs index 1c2886c56..bd7e2c068 100644 --- a/crates/conformance/src/protocol_corpus.rs +++ b/crates/conformance/src/protocol_corpus.rs @@ -928,7 +928,10 @@ pub enum ConformanceDecoder { /// #1650 adds the automation offset: the `Automation` message gains a required field 4, the /// nested `AutomationOffset` (`left_db` 1, `right_db` 2), so the transaction's `UpsertAutomation` /// carries it. Re-pinned from `0c7196f86abe6d57`; the frame count stays 46. -pub const COMPLETE_SCHEMA_HASH: u64 = 0x5eb9_ad2e_d137_f3be; +/// #1650b adds the control row's `automated` flag (#1650 D4): `ControlEntry` field 35, written on +/// every control row, and the list page's live `ratio` row is automated. Re-pinned from +/// `5eb9ad2ed137f3be`; the frame count stays 46. +pub const COMPLETE_SCHEMA_HASH: u64 = 0x5cab_2cf3_b4e4_767f; /// Build every command, successful response, registered non-OK status, event, and all-opcode /// session transaction using only public typed encoder entry points. @@ -1370,11 +1373,14 @@ fn control_corpus_pages() -> (ControlListPage, ControlStatePage) { family: ControlFamily::Effect, active: true, value: 4.0, + automated: false, }; let mut live = row("ratio", ControlKind::Continuous, ControlClass::Live); live.disabled_value = Some(0.0); live.ramp_row = Some(ControlRampRow::Fader); live.default_ramp_samples = 480; + // #1650 D4: the corpus carries an automated row. + live.automated = true; let mut toggle = row("bypass", ControlKind::Toggle, ControlClass::Live); toggle.minimum = 0.0; toggle.maximum = 1.0; diff --git a/crates/control-plane/tests/vca_automation_realtime.rs b/crates/control-plane/tests/vca_automation_realtime.rs index 4669ed236..a9d8f7046 100644 --- a/crates/control-plane/tests/vca_automation_realtime.rs +++ b/crates/control-plane/tests/vca_automation_realtime.rs @@ -8,6 +8,9 @@ //! inside one block. Every render call after the warm-up allocates and frees nothing, measured //! with the audited allocator's thread-scoped counters: the block that reads the offsets cell and //! jumps, and every block of the jump and the grid after it. +//! +//! #1650b gate 6 runs the same rig with hand moves of `eq0`'s automated fader (`SET_CONTROL +//! fader_db`, which writes the ride's offset and the lane's offsets cell) in place of the VCA moves. use control_plane::{ CompiledChildren, ControlAdapter, ControlLimits, EventLane, PlanResources, PlanState, @@ -175,15 +178,24 @@ impl Rig { /// Commit `v`'s offset at `db` on both lanes, over `ramp` samples or the session's length. fn move_vca(&mut self, db: f32, ramp: Option) { - self.request += 1; - let edits = [SessionEdit::SetControl { - expected_effect: None, - address: ControlAddress { + self.move_row( + ControlAddress { owner: Owner::Vca(id("v")), section: Section::Own, control: 1, lane: ParameterChannel::Both, }, + db, + ramp, + ); + } + + /// Commit `value` at `address` live, over `ramp` samples or the session's length. + fn move_row(&mut self, address: ControlAddress, db: f32, ramp: Option) { + self.request += 1; + let edits = [SessionEdit::SetControl { + expected_effect: None, + address, value: db, ramp_samples: ramp, }]; @@ -203,7 +215,7 @@ impl Rig { let length = self .session .command(&bytes, 65_536) - .expect("the VCA move commits"); + .expect("the move commits"); let mut fields = [0_u16; 64]; let path = match codec .decode_typed_response( @@ -217,11 +229,11 @@ impl Rig { .. } => applied.edit_path, DecodedTypedResponseFrame::NonOk { payload, .. } => { - panic!("the VCA move is refused: {payload:?}") + panic!("the move is refused: {payload:?}") } DecodedTypedResponseFrame::Success { .. } => panic!("another success payload"), }; - assert_eq!(path, EditPath::Live, "the VCA move is live"); + assert_eq!(path, EditPath::Live, "the move is live"); self.revision += 1; // The commit's reliable events, read as a host reads them. while self @@ -233,11 +245,8 @@ impl Rig { } } -/// Gate 6. Test value: red if the offsets cell's read, the jump or the composition with the new -/// `S` allocates or frees on render. -#[test] -fn a_vca_move_over_an_automated_member_renders_allocation_free() { - bench_support::alloc::assert_installed(); +/// The rig on [`document`], warmed: the process statics and the first blocks' one-time work. +fn rig() -> Rig { let (document, revision) = document(); let CompiledChildren { session, plan } = compile_children::(&document, limits(), Box::new(Preparer)).expect("boot compile"); @@ -248,10 +257,18 @@ fn a_vca_move_over_an_automated_member_renders_allocation_free() { request: 1, block: 0, }; - // Warm-up: the process statics and the first blocks' one-time work. for _ in 0..4 { rig.block(); } + rig +} + +/// Gate 6. Test value: red if the offsets cell's read, the jump or the composition with the new +/// `S` allocates or frees on render. +#[test] +fn a_vca_move_over_an_automated_member_renders_allocation_free() { + bench_support::alloc::assert_installed(); + let mut rig = rig(); let script: [&[(f32, Option)]; 4] = [ &[(-6.0, None)], &[(-3.0, Some(256))], @@ -274,3 +291,46 @@ fn a_vca_move_over_an_automated_member_renders_allocation_free() { } } } + +/// #1650b gate 6. Hand moves of `eq0`'s automated fader commit live (each writes the ride's offset +/// and the lane's offsets cell): on the left lane with the session's length, on both lanes with an +/// edit length of 256 samples, twice before one block, and back to `0`. Every render call after +/// the warm-up allocates and frees nothing. +/// +/// Test value: red if the offsets-cell read, the jump or the composition with a hand offset's `S` +/// allocates or frees on render. +#[test] +fn a_hand_move_on_an_automated_fader_renders_allocation_free() { + bench_support::alloc::assert_installed(); + let mut rig = rig(); + let fader = |lane| ControlAddress { + owner: Owner::Strip(id("eq0")), + section: Section::Builtins, + control: 5, + lane, + }; + let script: [&[(ParameterChannel, f32, Option)]; 4] = [ + &[(ParameterChannel::Left, 3.0, None)], + &[(ParameterChannel::Both, -4.5, Some(256))], + // Two moves before one block: render reads the newest slot only. + &[ + (ParameterChannel::Right, 6.0, None), + (ParameterChannel::Left, -1.5, Some(256)), + ], + &[(ParameterChannel::Both, 0.0, None)], + ]; + for moves in script { + for &(lane, db, ramp) in moves { + rig.move_row(fader(lane), db, ramp); + } + for _ in 0..12 { + let block = rig.block; + let used = rig.block(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "block {block} allocated or freed" + ); + } + } +} diff --git a/crates/controls/src/builtin_rows.rs b/crates/controls/src/builtin_rows.rs index 2ccd42c96..e5d49148a 100644 --- a/crates/controls/src/builtin_rows.rs +++ b/crates/controls/src/builtin_rows.rs @@ -3,12 +3,17 @@ //! The descriptor table says what each value is. This module is the only place that says which //! model field each builtin ID reads and writes: one `match` arm per ID in [`read`] and in //! [`write`]. +//! +//! The binding has model scope for one row (#1650 D3, root's ruling R17-1 = A): on a lane that a +//! rendered stored automation drives, `fader_db` reads and writes the driving entry's hand-move +//! offset ([`FaderOffsets`], [`write_fader_offset`]), not the strip's static fader, which is then +//! the lane's fallback and no control value. use builtins::{ BUILTIN_PARAMETER_DESCRIPTORS, BuiltinParameterDescriptor, BuiltinParameterDomain, BuiltinParameterScope, builtin_filter_cutoff_maximum_hz, builtin_parameter_unit, pan_matrix, }; -use session::{ChannelBuiltins, MatrixOrPan, StripRef}; +use session::{Automation, ChannelBuiltins, Lane, MatrixOrPan, StableId, StripRef}; use crate::ControlScope; use crate::descriptor::{AutomationRule, ControlDescriptor, DomainRule, Lanes, Motion}; @@ -112,13 +117,79 @@ pub(crate) fn shaped_descriptor( } } +/// The hand-move offsets of a strip's fader lanes (#1650 D3): per lane (`[left, right]`), the +/// offset of the rendered stored automation entry that drives the lane, or `None` for a lane that +/// no rendered entry drives. The `fader_db` row reads a lane's offset where it is `Some`. +pub(crate) type FaderOffsets = [Option; 2]; + +/// The [`FaderOffsets`] of the entries that drive a strip's fader lanes +/// ([`crate::fader_automation`], or [`crate::ModelView::fader_automation`]). +pub(crate) fn fader_offsets(entries: [Option<&Automation>; 2]) -> FaderOffsets { + [ + entries[0].map(|entry| entry.offset.left_db), + entries[1].map(|entry| entry.offset.right_db), + ] +} + +/// The rendered entries of `model` that drive strip `strip`'s fader lanes: +/// [`crate::fader_automation`], with an early out on an empty table. +fn fader_entries<'a>( + model: &'a session::SessionModel, + strip: &StableId, +) -> [Option<&'a Automation>; 2] { + if model.automation.is_empty() { + return [None, None]; + } + crate::automation::fader_automation(model, strip) +} + +/// The [`FaderOffsets`] of strip `strip` in a whole model. +pub(crate) fn model_fader_offsets(model: &session::SessionModel, strip: &StableId) -> FaderOffsets { + fader_offsets(fader_entries(model, strip)) +} + +/// Write `value` as the hand-move offset of each side in `sides` of strip `strip`'s fader that a +/// rendered entry of `model` drives (#1650 D3), and return the sides no entry drives, which keep +/// the static fader write. The caller has run [`crate::check`] with the row's own domain, +/// `[-144, 24]` dB, which is the offset domain (#1650 D2). +pub(crate) fn write_fader_offset( + model: &mut session::SessionModel, + strip: &StableId, + sides: &[Side], + value: f32, +) -> [bool; 2] { + let ids = fader_entries(model, strip).map(|entry| entry.map(|entry| entry.id.clone())); + let mut static_sides = [false; 2]; + for &side in sides { + let lane = side.pick(0, 1); + let Some(id) = &ids[lane] else { + static_sides[lane] = true; + continue; + }; + if let Some(entry) = model.automation.iter_mut().find(|entry| entry.id == *id) { + debug_assert!(matches!( + (entry.target.lane, side), + (Lane::Both, _) | (Lane::Left, Side::Left) | (Lane::Right, Side::Right) + )); + *side.pick(&mut entry.offset.left_db, &mut entry.offset.right_db) = value; + } + } + static_sides +} + fn channel<'a>(strip: &StripRef<'a>, side: Side) -> &'a ChannelBuiltins { side.pick(&strip.builtins.left, &strip.builtins.right) } /// The committed value of builtin `row` on `side`. A row that the strip's shape does not use -/// reads as its default. -pub(crate) fn read(strip: &StripRef<'_>, row: &BuiltinParameterDescriptor, side: Side) -> f32 { +/// reads as its default. `offsets` are the strip's [`FaderOffsets`]: an automated fader lane reads +/// its entry's hand-move offset (#1650 D3). +pub(crate) fn read( + strip: &StripRef<'_>, + row: &BuiltinParameterDescriptor, + side: Side, + offsets: FaderOffsets, +) -> f32 { let lane = channel(strip, side); let fader = strip.fader; match row.id { @@ -126,7 +197,9 @@ pub(crate) fn read(strip: &StripRef<'_>, row: &BuiltinParameterDescriptor, side: TRIM_DB => lane.trim_db, HPF_HZ => lane.hpf_hz, LPF_HZ => lane.lpf_hz, - FADER_DB => side.pick(fader.left_db, fader.right_db), + FADER_DB => side + .pick(offsets[0], offsets[1]) + .unwrap_or_else(|| side.pick(fader.left_db, fader.right_db)), MUTE => toggle(side.pick(fader.left_mute, fader.right_mute)), DELAY_SAMPLES => lane.delay_samples as f32, PAN => match strip.matrix_or_pan { @@ -147,7 +220,8 @@ pub(crate) fn read(strip: &StripRef<'_>, row: &BuiltinParameterDescriptor, side: } /// Write builtin `id` on `side` (ignored by a shared row). The caller has run [`crate::check`], so -/// the value is in domain and the row is active. +/// the value is in domain and the row is active. A `fader_db` lane that a rendered entry drives is +/// written by [`write_fader_offset`], never here. /// /// # Errors /// diff --git a/crates/controls/src/effective.rs b/crates/controls/src/effective.rs index 53a7f3e0f..c68378a84 100644 --- a/crates/controls/src/effective.rs +++ b/crates/controls/src/effective.rs @@ -198,11 +198,16 @@ pub struct EffectiveStrip { pub mute: [bool; 2], /// Whether any reaching VCA mutes the lane. pub vca_mute: [bool; 2], - /// `S`, the lane's offset sum (#1646 D1, D6): [`session::vca_offsets_sum`] of the reaching - /// VCAs' offsets, in ascending VCA-ID order, and `+0.0` when no VCA reaches the lane. A stored - /// fader automation value `v` renders as `session::vca_compose_db(v, S)`; the static path - /// composes the member with the same sum (`fader_db`). + /// `S`, the lane's offset sum (#1646 D1, D6; #1650 D6): the `f64` sum of the lane's own + /// hand-move offset ([`Self::fader_offset`], when an entry drives the lane) first, then the + /// reaching VCAs' offsets in ascending VCA-ID order ([`session::vca_offsets_sum`]'s order), and + /// `+0.0` when there is neither. A stored fader automation value `v` renders as + /// `session::vca_compose_db(v, S)`. The static path composes the member with the VCA offsets + /// alone (`fader_db`): a lane with no driving entry has no hand offset, so the two agree there. pub offsets_sum: [f64; 2], + /// The hand-move offset of the rendered stored automation entry that drives each lane (#1650 + /// D3), or `None` for a lane no rendered entry drives. + pub fader_offset: [Option; 2], } /// One route's effective values. @@ -287,6 +292,9 @@ pub trait EffectiveInputs { fn reach(&self, strip: usize) -> &[usize]; /// A VCA's offset (`[left, right]` dB) and mute. fn vca(&self, vca: usize) -> ([f32; 2], [bool; 2]); + /// The hand-move offset (dB) of the rendered stored automation entry that drives each of the + /// strip's fader lanes, or `None` for a lane no rendered entry drives (#1650 D3, D6). + fn fader_offset(&self, strip: usize) -> [Option; 2]; /// The number of routes. fn route_count(&self) -> usize; /// One route's inputs. @@ -310,7 +318,8 @@ pub fn solo_term(inputs: &impl EffectiveInputs, strip: usize, any_solo: bool) -> /// One strip's effective values. `any_solo` is [`any_solo`]'s result for the same inputs. /// /// Per lane: `fader_db = vca_effective_db(own, the reaching VCAs' offsets in ascending VCA-ID -/// order)`; `offsets_sum` = their [`session::vca_offsets_sum`], or `+0.0` with none; `vca_mute` = +/// order)`; `offsets_sum` = the `f64` sum of the lane's hand-move offset (when a rendered entry +/// drives the lane) and then those VCA offsets, or `+0.0` with none (#1650 D6); `vca_mute` = /// any reaching VCA mutes; `mute = own || vca_mute || solo_term` with /// `solo_term = any_solo && !solo_safe && !soloed`. #[must_use] @@ -322,20 +331,31 @@ pub fn effective_strip( let (own_db, own_mute) = inputs.own_fader(strip); let reach = inputs.reach(strip); let solo_term = solo_term(inputs, strip, any_solo); - let mut out = EffectiveStrip::default(); + let mut out = EffectiveStrip { + fader_offset: inputs.fader_offset(strip), + ..EffectiveStrip::default() + }; // One read of each reaching VCA feeds both lanes; the sum is `vca_effective_db`'s: the - // offsets first, then the member. + // offsets first, then the member. `S` is the lane's hand offset first, then the same offsets + // in the same order (#1650 D6). let mut sums = [VcaSum::new(); 2]; + let mut offsets = [VcaSum::new(); 2]; + for (sum, hand) in offsets.iter_mut().zip(out.fader_offset) { + if let Some(hand) = hand { + sum.add(hand); + } + } for &vca in reach { let (offset_db, muted) = inputs.vca(vca); for lane in 0..2 { sums[lane].add(offset_db[lane]); + offsets[lane].add(offset_db[lane]); out.vca_mute[lane] |= muted[lane]; } } for lane in 0..2 { out.fader_db[lane] = sums[lane].compose(own_db[lane]); - out.offsets_sum[lane] = sums[lane].sum().unwrap_or(0.0); + out.offsets_sum[lane] = offsets[lane].sum().unwrap_or(0.0); out.mute[lane] = own_mute[lane] || out.vca_mute[lane] || solo_term; } out @@ -422,6 +442,9 @@ pub struct ModelInputs<'a> { reach: Vec>, /// The strip index of each route's source, or the strip count when it names none. sources: Vec, + /// The hand-move offsets of each strip's automated fader lanes (#1650), indexed by strip; + /// empty when the model stores no rendered fader automation. + fader_offsets: Vec<[Option; 2]>, } impl<'a> ModelInputs<'a> { @@ -445,6 +468,7 @@ impl<'a> ModelInputs<'a> { overlay, reach: model.vca_reach(), sources, + fader_offsets: model_fader_offsets(model), } } @@ -458,6 +482,47 @@ impl<'a> ModelInputs<'a> { } } +/// The [`EffectiveInputs::fader_offset`] of every strip of `model`, by strip index: one pass over +/// the automation table ([`crate::fader_automation`]'s rule: the first rendered entry that drives a +/// lane), or an empty table when no entry renders. +pub(crate) fn model_fader_offsets(model: &SessionModel) -> Vec<[Option; 2]> { + let mut offsets = Vec::new(); + for entry in &model.automation { + let target = &entry.target; + let session::Owner::Strip(id) = &target.owner else { + continue; + }; + if !crate::automation_row_renders(target) { + continue; + } + let Some(index) = crate::registry::strip_index(model, id) else { + continue; + }; + if offsets.is_empty() { + offsets = vec![[None; 2]; model.tracks.len() + model.submixes.len()]; + } + let lanes: &mut [Option; 2] = &mut offsets[index]; + for (lane, (drives, offset)) in [ + ( + matches!(target.lane, session::Lane::Left | session::Lane::Both), + entry.offset.left_db, + ), + ( + matches!(target.lane, session::Lane::Right | session::Lane::Both), + entry.offset.right_db, + ), + ] + .into_iter() + .enumerate() + { + if drives && lanes[lane].is_none() { + lanes[lane] = Some(offset); + } + } + } + offsets +} + fn source_id(route: &Route) -> &StableId { match &route.source { RouteSource::Track { track_id, .. } => track_id, @@ -504,6 +569,10 @@ impl EffectiveInputs for ModelInputs<'_> { ) } + fn fader_offset(&self, strip: usize) -> [Option; 2] { + self.fader_offsets.get(strip).copied().unwrap_or([None; 2]) + } + fn route_count(&self) -> usize { self.model.routes.len() } @@ -642,6 +711,12 @@ impl EffectiveInputs for LocalInputs<'_> { ) } + fn fader_offset(&self, strip: usize) -> [Option; 2] { + strip_id_at(self.view.structure(), strip).map_or([None; 2], |id| { + crate::builtin_rows::fader_offsets(self.view.fader_automation(id)) + }) + } + fn route_count(&self) -> usize { self.view.structure().routes.len() } diff --git a/crates/controls/src/mask.rs b/crates/controls/src/mask.rs index 86f6c3784..9b8a14186 100644 --- a/crates/controls/src/mask.rs +++ b/crates/controls/src/mask.rs @@ -47,15 +47,31 @@ struct Target<'a> { /// single supported link mode's `link_mode`), so that a change to it stays a difference of /// structure and a rebuild reports it. /// +/// A fader lane that a rendered stored automation of `into` drives (#1650 D3, departure 2) has +/// the driving entry's hand-move offset as its `fader_db` value: the offset is copied (from the +/// entry of `from` with the same ID and target), and the strip's static fader dB of that lane is +/// left as `into` holds it. It is the lane's fallback, not a control value, so a change of it stays +/// a difference of structure. +/// /// After this call, `into` and `from` differ only in what is not a control value, and in the /// owners they do not share. pub fn mask_values(into: &mut SessionModel, from: &SessionModel) { into.control_smoothing = from.control_smoothing; + mask_fader_offsets(into, from); + // One pass over the table: the lanes each strip's rendered entries drive, by strip index. + let driven = crate::effective::model_fader_offsets(into); + let automated = |index: usize| { + driven + .get(index) + .map_or([false; 2], |lanes| lanes.map(|offset| offset.is_some())) + }; + let tracks = into.tracks.len(); let console = &into.console; for (index, track) in into.tracks.iter_mut().enumerate() { if let Some(source) = pair(&from.tracks, index, |candidate| candidate.id == track.id) { mask_strip( console, + automated(index), Target { builtins: &mut track.builtins, fader: &mut track.fader, @@ -77,6 +93,7 @@ pub fn mask_values(into: &mut SessionModel, from: &SessionModel) { if let Some(source) = pair(&from.submixes, index, |candidate| candidate.id == submix.id) { mask_strip( console, + automated(tracks + index), Target { builtins: &mut submix.builtins, fader: &mut submix.fader, @@ -114,10 +131,35 @@ fn pair(items: &[T], index: usize, matches: impl Fn(&T) -> bool) -> Option<&T .or_else(|| items.iter().find(|item| matches(item))) } +/// Copy the hand-move offset of every rendered entry of `into` from the entry of `from` with the +/// same ID and target (#1650 D5): an offset is a control value, the value of the `fader_db` row of +/// the lanes the entry drives. +fn mask_fader_offsets(into: &mut SessionModel, from: &SessionModel) { + for (index, entry) in into.automation.iter_mut().enumerate() { + if !crate::automation_row_renders(&entry.target) { + continue; + } + if let Some(prior) = pair(&from.automation, index, |candidate| { + candidate.id == entry.id && candidate.target == entry.target + }) { + entry.offset = prior.offset; + } + } +} + /// Mask one strip. `console` is the session's slot declaration, which gives each entry's identity. -fn mask_strip(console: &Console, into: Target<'_>, from: &Source<'_>) { +/// `automated` says which fader lanes a rendered entry of the model being masked drives: their +/// static dB is kept (#1650 departure 2). +fn mask_strip(console: &Console, automated: [bool; 2], into: Target<'_>, from: &Source<'_>) { into.builtins.clone_from(from.builtins); + let kept = [into.fader.left_db, into.fader.right_db]; into.fader.clone_from(from.fader); + if automated[0] { + into.fader.left_db = kept[0]; + } + if automated[1] { + into.fader.right_db = kept[1]; + } into.matrix_or_pan.clone_from(from.matrix_or_pan); for (index, entry) in into.console.iter_mut().enumerate() { let Some(slot) = console.slots().find(|slot| slot.slot == entry.slot) else { diff --git a/crates/controls/src/owner_edits.rs b/crates/controls/src/owner_edits.rs index 5c32b1f72..44d529a19 100644 --- a/crates/controls/src/owner_edits.rs +++ b/crates/controls/src/owner_edits.rs @@ -5,12 +5,18 @@ //! on it: the same lookups, checks, value normalization and write as on a whole model, which reads //! only the owner and the session header. The result is the owners as the transaction leaves them, //! and where each sits in the committed model, with no clone of the session. +//! +//! A hand move on an automated fader writes the hand-move offset of the stored automation entry +//! that drives the lane (#1650 D3), so that entry is an owner of the edit too (root's ruling +//! R17-1 = A): a `fader_db` write clones the rendered entries that drive the strip's fader lanes +//! with the strip, and [`crate::set`] on the owners model reads and writes them as it would on the +//! whole model. use session::{OwnerSlots, SessionModel}; use crate::error::ControlError; use crate::view::position; -use session::{ControlAddress, Owner}; +use session::{ControlAddress, Owner, Section}; /// Why an edit is not one the owners model can hold. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -83,10 +89,31 @@ impl<'m> OwnerEdits<'m> { sample_rate: u32, ) -> Result<(), OwnerEditError> { self.take(&address.owner)?; + if let Owner::Strip(strip) = &address.owner + && address.section == Section::Builtins + && address.control == crate::builtin_rows::FADER_DB + { + self.take_fader_automation(strip); + } crate::registry::set(&mut self.owners, address, value, ramp, sample_rate) .map_err(OwnerEditError::Control) } + /// Clone the rendered entries that drive strip `strip`'s fader lanes into the owners model, in + /// ID order, unless they are there already (#1650, R17-1 = A). A scan of the committed table, + /// with an early out when it is empty. + fn take_fader_automation(&mut self, strip: &session::StableId) { + if self.committed.automation.is_empty() { + return; + } + for entry in crate::automation::fader_automation(self.committed, strip) + .into_iter() + .flatten() + { + insert_sorted(&mut self.owners.automation, entry, |entry| &entry.id); + } + } + /// Clone the owner into the owners model, in ID order, unless it is there already. fn take(&mut self, owner: &Owner) -> Result<(), OwnerEditError> { let unknown = OwnerEditError::Control(ControlError::AddressUnknown); @@ -151,6 +178,11 @@ impl<'m> OwnerEdits<'m> { .expect("an edited route came from the committed model"); slots.routes.push((at, home)); } + for (at, entry) in self.owners.automation.iter().enumerate() { + let home = position(&committed.automation, &entry.id, |entry| &entry.id) + .expect("an edited automation entry came from the committed model"); + slots.automation.push((at, home)); + } (self.owners, slots) } } diff --git a/crates/controls/src/registry.rs b/crates/controls/src/registry.rs index 95848cf0c..e9a564619 100644 --- a/crates/controls/src/registry.rs +++ b/crates/controls/src/registry.rs @@ -23,6 +23,9 @@ pub struct ControlRow { /// The committed value. A derived row reports its computed value, a monitor row (solo) the /// overlay's, and a row that the owner's shape does not use the descriptor's default. pub value: f32, + /// Whether a rendered stored automation drives the row's lane (#1650 D4). On such a lane the + /// `fader_db` row's value is the driving entry's hand-move offset, not the static fader. + pub automated: bool, } /// Append the rows of one control: one per lane the descriptor lists. `value` gives the committed @@ -34,6 +37,20 @@ pub(crate) fn push_control( control: u32, descriptor: &ControlDescriptor, value: impl Fn(Side) -> f32, +) { + push_control_automated(out, owner, section, control, descriptor, value, |_| false); +} + +/// [`push_control`] for a row that a rendered stored automation can drive: `automated` says, per +/// side, whether one does (#1650 D4). +pub(crate) fn push_control_automated( + out: &mut Vec, + owner: &Owner, + section: &Section, + control: u32, + descriptor: &ControlDescriptor, + value: impl Fn(Side) -> f32, + automated: impl Fn(Side) -> bool, ) { for &lane in descriptor.lanes.listed() { out.push(ControlRow { @@ -49,6 +66,7 @@ pub(crate) fn push_control( } else { descriptor.default }, + automated: automated(Side::of(lane)), }); } } @@ -200,10 +218,12 @@ pub fn owner_rows_in( match owner_slot(model, owner) { OwnerSlot::Strip(index) => { let strip = view.strip(index); + let offsets = builtin_rows::fader_offsets(view.fader_automation(strip.id)); strip::push_rows( out, &model.console, &strip, + offsets, &effective.strip(index), overlay, sample_rate, @@ -533,9 +553,16 @@ fn write( .try_for_each(|&side| strip::write_own(&mut target, control, side, value)) } (Owner::Strip(id), Section::Builtins) => { + // An automated fader lane's value is its entry's hand-move offset (#1650 D3). + let static_sides = if control == builtin_rows::FADER_DB { + builtin_rows::write_fader_offset(model, id, sides, value) + } else { + [true; 2] + }; let mut target = strip::strip_mut(model, id).ok_or(ControlError::AddressUnknown)?; sides .iter() + .filter(|&&side| static_sides[side.pick(0, 1)]) .try_for_each(|&side| builtin_rows::write(&mut target, control, side, value)) } (Owner::Strip(_), Section::Console(_) | Section::Inserts(_)) => { diff --git a/crates/controls/src/strip.rs b/crates/controls/src/strip.rs index 13567704d..20b41f1cf 100644 --- a/crates/controls/src/strip.rs +++ b/crates/controls/src/strip.rs @@ -16,7 +16,7 @@ use crate::effective::{EffectiveStrip, MonitorOverlay, MonitorRow}; use crate::error::ControlError; use crate::lane::Side; use crate::lane::{is_on, toggle}; -use crate::registry::{ControlRow, Resolved, Source, push_control}; +use crate::registry::{ControlRow, Resolved, Source, push_control, push_control_automated}; use crate::{ControlClass, ControlKind, ControlScope, Family, LiveRampRow, Taper}; use session::{Owner, Section}; @@ -286,10 +286,14 @@ pub(crate) fn strip_mut<'a>(model: &'a mut SessionModel, id: &StableId) -> Optio /// Append every control row of one strip, in listing order: own, then builtins, then the effect /// rows in chain order (decision 17, C4): the pre-insert console slots, the inserts, then the /// post-insert console slots. +/// +/// `offsets` are the strip's [`builtin_rows::FaderOffsets`]: an automated fader lane lists its +/// entry's hand-move offset as the `fader_db` value and is marked `automated` (#1650 D3, D4). pub(crate) fn push_rows( out: &mut Vec, console: &Console, strip: &StripRef<'_>, + offsets: builtin_rows::FaderOffsets, effective: &EffectiveStrip, overlay: &MonitorOverlay, sample_rate: u32, @@ -298,13 +302,14 @@ pub(crate) fn push_rows( push_own_rows(out, strip, effective, overlay, false); for row in builtin_rows::rows() { let descriptor = builtin_rows::descriptor(row, sample_rate, strip.matrix_or_pan); - push_control( + push_control_automated( out, &owner, &Section::Builtins, row.id, &descriptor, - |side| builtin_rows::read(strip, row, side), + |side| builtin_rows::read(strip, row, side, offsets), + |side| row.id == builtin_rows::FADER_DB && side.pick(offsets[0], offsets[1]).is_some(), ); } // Chain order: the pre-insert slots, the inserts, then the post-insert slots, each in declared @@ -410,8 +415,14 @@ pub(crate) fn resolve( Section::Builtins => { let row = builtin_rows::row(control).ok_or(unknown)?; let descriptor = builtin_rows::descriptor(row, sample_rate, strip.matrix_or_pan); + // Only the fader row has model scope (#1650 D3): the entries are read for it alone. + let offsets = if control == builtin_rows::FADER_DB { + builtin_rows::model_fader_offsets(model, id) + } else { + [None; 2] + }; Ok(Resolved::stored(descriptor, |side| { - builtin_rows::read(&strip, row, side) + builtin_rows::read(&strip, row, side, offsets) })) } Section::Console(slot) => { diff --git a/crates/controls/src/view.rs b/crates/controls/src/view.rs index d83f42044..1803baca9 100644 --- a/crates/controls/src/view.rs +++ b/crates/controls/src/view.rs @@ -6,12 +6,14 @@ //! The classifier, the catalog stage and the effective values read the "next" model through it, so a //! commit of values clones, compiles and lists no more than the owners it changed. -use session::{Route, SessionModel, StableId, StripRef, Vca}; +use session::{Automation, Route, SessionModel, StableId, StripRef, Vca}; use crate::registry::strip_at; -/// The committed model (`structure`: counts, order, IDs, header, console slots) and the edited -/// owners (`owners`: a small model with the header and the edited clones in ID order), read as one. +/// The committed model (`structure`: counts, order, IDs, header, console slots, the automation +/// table's entries and targets) and the edited owners (`owners`: a small model with the header and +/// the edited clones in ID order, among them the automation entries whose offset a hand move wrote, +/// #1650), read as one. /// For a model that is whole, both are the same model. #[derive(Clone, Copy, Debug)] pub struct ModelView<'a> { @@ -97,6 +99,27 @@ impl<'a> ModelView<'a> { find(&self.owners.vcas, &base.id, |vca| &vca.id).unwrap_or(base) } + /// The rendered stored automation entries that drive strip `strip`'s fader, per lane + /// (`[left, right]`, [`crate::fader_automation`]), each the edited entry when the view holds + /// one (#1650, R17-1 = A): a hand move on an automated fader writes the driving entry's offset, + /// so the binding of the strip's `fader_db` row and the lane's `S` read the entry here. + /// + /// A transaction of values leaves the automation table's entries and targets as committed, so + /// which entry drives a lane is read from the committed table, and only its offset from the + /// edit. Linear in the committed table's length, with an early out when it is empty. + #[must_use] + pub fn fader_automation(&self, strip: &StableId) -> [Option<&'a Automation>; 2] { + let lanes = crate::automation::fader_automation(self.structure, strip); + if self.is_whole() { + return lanes; + } + lanes.map(|entry| { + entry.map(|entry| { + find(&self.owners.automation, &entry.id, |held| &held.id).unwrap_or(entry) + }) + }) + } + /// The dense model the view stands for: a clone of the committed model with the edited owners /// in place. Linear in the session; for tests and for a caller that has no view-shaped code. #[must_use] @@ -125,6 +148,11 @@ impl<'a> ModelView<'a> { model.routes[at] = route.clone(); } } + for automation in &self.owners.automation { + if let Some(at) = position(&model.automation, &automation.id, |entry| &entry.id) { + model.automation[at] = automation.clone(); + } + } model } } diff --git a/crates/controls/tests/composition.rs b/crates/controls/tests/composition.rs index 7abc08d4f..e61c2bef2 100644 --- a/crates/controls/tests/composition.rs +++ b/crates/controls/tests/composition.rs @@ -192,6 +192,7 @@ fn vca_reach_counts_a_diamond_once_and_includes_nested_parents_in_id_order() { mute: [true, vocal.right_mute], vca_mute: [true, false], offsets_sum: [-1.0, -1.0], + fader_offset: [None; 2], }, EffectiveStrip { fader_db: [1.0 - 2.0 - 3.0 - 4.0, -2.5 + 0.125 + 0.25 + 0.5], @@ -199,6 +200,7 @@ fn vca_reach_counts_a_diamond_once_and_includes_nested_parents_in_id_order() { vca_mute: [false, true], // The reach in ascending VCA ID: `a-mid`, `b-mid`, `z-top`. offsets_sum: [-2.0 - 3.0 - 4.0, 0.125 + 0.25 + 0.5], + fader_offset: [None; 2], }, EffectiveStrip { fader_db: [ @@ -212,12 +214,14 @@ fn vca_reach_counts_a_diamond_once_and_includes_nested_parents_in_id_order() { vca_mute: [false, false], // No VCA reaches `t1`: `S` is `+0.0`. offsets_sum: [0.0, 0.0], + fader_offset: [None; 2], }, EffectiveStrip { fader_db: [bus.left_db - 3.0 - 4.0, bus.right_db + 0.25 + 0.5], mute: [bus.left_mute, true], vca_mute: [false, true], offsets_sum: [-3.0 - 4.0, 0.25 + 0.5], + fader_offset: [None; 2], }, ]; let actual = effective(&model, &MonitorOverlay::default()); diff --git a/crates/controls/tests/effective.rs b/crates/controls/tests/effective.rs index 27500e99a..a0d1a9256 100644 --- a/crates/controls/tests/effective.rs +++ b/crates/controls/tests/effective.rs @@ -239,6 +239,7 @@ fn effective_into_allocates_nothing_and_refuses_a_wrong_shape() { mute: BOTH_ON, vca_mute: BOTH_ON, offsets_sum: [9.0; 2], + fader_offset: [None; 2], }); let before = wrong.clone(); assert_eq!( diff --git a/crates/host-core/src/control_provider.rs b/crates/host-core/src/control_provider.rs index dd5e3e886..0d36a8116 100644 --- a/crates/host-core/src/control_provider.rs +++ b/crates/host-core/src/control_provider.rs @@ -1975,6 +1975,8 @@ struct Shape { automatable: bool, family: ControlFamily, active: bool, + /// #1650 D4: a rendered stored automation drives the row's lane. + automated: bool, } impl Eq for Shape {} @@ -2085,6 +2087,7 @@ impl Shape { controls::Family::Setting => ControlFamily::Setting, }, active: descriptor.active, + automated: row.automated, } } @@ -2138,6 +2141,7 @@ impl Shape { family: self.family, active: self.active, value: 0.0, + automated: self.automated, }) } } @@ -3087,6 +3091,115 @@ mod tests { ); } + /// #1650b gate 5 (the catalog). A left-lane fader entry on `eq0` with a `+2.5` dB hand + /// offset lists `eq0`'s left `fader_db` row `automated` with the offset as its value, and the + /// right row (no entry drives it) not `automated`, at its static value. A hand move (the offset + /// moved) keeps the epoch; adding the entry and removing it each advance it, and a client's + /// old epoch is then stale. + /// + /// Test value: red if a client cannot tell that its write on an automated lane is an offset + /// (no flag, or the static value listed), or if the flag can change under a held epoch. + #[test] + fn an_automated_fader_lane_lists_its_offset_and_the_flag_is_a_descriptor() { + let plain = nine_track(); + let (mut provider, _clock) = provider_of(&plain); + let left = fader_address(&plain, "eq0"); + let mut automated = plain.clone(); + automated.automation.push(session::Automation { + id: session::StableId::parse("eq0-ride").expect("ID"), + target: left.clone(), + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: 4_800, + start_value: 0.0, + end_value: -6.0, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset { + left_db: 2.5, + right_db: 0.0, + }, + }); + let rows = |provider: &mut SessionControlProvider| { + whole_list(provider, 256) + .into_iter() + .filter(|entry| { + entry.address.owner_id.as_deref() == Some("eq0") + && entry.address.section == ControlSection::Builtins + && entry.address.control == Some(left.control) + }) + .map(|entry| match entry.body { + ControlBody::Control(row) => (entry.address.lane, row.value, row.automated), + _ => unreachable!("a control row"), + }) + .collect::>() + }; + let static_db = [ + plain.tracks[0].fader.left_db, + plain.tracks[0].fader.right_db, + ]; + assert_eq!( + rows(&mut provider), + vec![ + (Some(ParameterChannel::Left), static_db[0], false), + (Some(ParameterChannel::Right), static_db[1], false), + ] + ); + provider.replace_session_catalog( + ControlCatalog::build(&automated, &MonitorOverlay::default(), RATE).expect("catalog"), + 2, + ); + assert_eq!( + provider.structure_epoch(), + 2, + "the flag is a descriptor field" + ); + assert_eq!( + rows(&mut provider), + vec![ + (Some(ParameterChannel::Left), 2.5, true), + (Some(ParameterChannel::Right), static_db[1], false), + ] + ); + // A hand move writes the offset, a value: the epoch holds. + controls::set(&mut automated, &left, -1.5, None, RATE).expect("a hand move"); + assert_eq!( + automated.automation[0].offset.left_db.to_bits(), + (-1.5_f32).to_bits() + ); + assert_eq!( + automated.tracks[0].fader.left_db.to_bits(), + static_db[0].to_bits(), + "the static fader is the lane's fallback" + ); + provider.replace_session_catalog( + ControlCatalog::build(&automated, &MonitorOverlay::default(), RATE).expect("catalog"), + 3, + ); + assert_eq!(provider.structure_epoch(), 2, "a hand move is a value"); + assert_eq!( + rows(&mut provider)[0], + (Some(ParameterChannel::Left), -1.5, true) + ); + provider.replace_session_catalog( + ControlCatalog::build(&plain, &MonitorOverlay::default(), RATE).expect("catalog"), + 4, + ); + assert_eq!(provider.structure_epoch(), 3, "the entry removed"); + assert_eq!( + provider + .control_list(&ControlListRequest { + structure_epoch: 2, + after_handle: 0, + limit: 1, + owner: None, + }) + .err(), + Some(ParameterProviderError::Stale) + ); + } + #[test] fn a_commit_refreshes_values_and_only_a_changed_address_sequence_advances_the_epoch() { let mut model = nine_track(); diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index dfc0f492b..3f03604b1 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -1235,6 +1235,8 @@ impl<'a> LocalDiff<'a> { StripChange { own_before: strip_at(self.rules.current, strip.index).fader, own_after: after.fader, + offset_before: strip.before.fader_offset, + offset_after: strip.after.fader_offset, id: after.id, reach_before: &strip.reach_before, reach: &strip.reach, diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index ff2817bce..455109bcc 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -4365,6 +4365,11 @@ pub(crate) fn strip_at(model: &SessionModel, index: usize) -> StripRef<'_> { pub(crate) struct StripChange<'s> { pub(crate) own_before: &'s session::DualMonoFader, pub(crate) own_after: &'s session::DualMonoFader, + /// The hand-move offset of each automated fader lane in each model + /// ([`controls::EffectiveStrip::fader_offset`], #1650): a hand move on an automated fader + /// writes it, so it is the strip's own fader edit there. + pub(crate) offset_before: [Option; 2], + pub(crate) offset_after: [Option; 2], pub(crate) id: &'s session::StableId, /// [`SessionModel::vca_reach`] of the strip in `current`, then in `next`. pub(crate) reach_before: &'s [usize], @@ -4393,7 +4398,8 @@ impl LevelRules<'_> { /// The ramp length of each lane's `FaderDb` and `Mute` record of one strip (#1558 D7). /// /// A lane's fader length is that of the strip's own `fader_db` edit when the strip's own value - /// on the lane changed (row `Fader`, the edit's length); else that of the first reaching VCA, + /// on the lane changed, or its hand-move offset did on an automated lane (#1650 D5) (row + /// `Fader`, the edit's length); else that of the first reaching VCA, /// in ascending VCA-ID order, whose offset on the lane changed (row `VcaOffset`, the VCA's /// edit's length); else row `VcaOffset` with no edit length (a membership change, or a VCA /// added or removed). The mute is the same with rows `Mute` and `VcaMute`. @@ -4423,7 +4429,11 @@ impl LevelRules<'_> { }; let fader = [0, 1].map(|lane| { let db = |fader: &session::DualMonoFader| [fader.left_db, fader.right_db][lane]; - if db(own_before).to_bits() != db(own_after).to_bits() { + // A hand move on an automated lane (#1650 D5) is the strip's own fader edit. + let offset = |offsets: [Option; 2]| offsets[lane].map(f32::to_bits); + if db(own_before).to_bits() != db(own_after).to_bits() + || offset(strip.offset_before) != offset(strip.offset_after) + { self.ramps.resolve(LiveRampRow::Fader, edit.fader) } else { self.ramps @@ -4584,6 +4594,8 @@ impl<'a> EffectiveDiff<'a> { StripChange { own_before: strip_at(self.rules.current, before).fader, own_after: after_strip.fader, + offset_before: self.before.strips[before].fader_offset, + offset_after: self.after.strips[after].fader_offset, id: after_strip.id, reach_before: &self.reach_before[before], reach: &self.reach[after], diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 6d957455a..866a8b2f2 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -1711,6 +1711,142 @@ fn a_vca_move_writes_the_automated_lanes_offsets_cell() { } } +/// #1650b D3, D5 and root's ruling R17-1, in the shared classifier. Track 0 rides its fader on +/// both lanes under a VCA at -2 dB. A hand move (`SET_CONTROL fader_db`) on its left lane writes the +/// ride's left offset, not the static fader, and is live with one offsets-cell write of +/// `S = offset + VCA` on that lane, with the `Fader` row's length (the edit's own 256 samples, not +/// the VCA row's), and no `FaderDb`. An offset-only `UpsertAutomation` is the same live write. A +/// change of an automated lane's static fader (the fallback) is a rebuild. The fast path over the +/// whole model, and over the in-place view whose owners hold the ride (`controls::OwnerEdits`), +/// give the full classifier's delta. +/// +/// Test value: red if the hand move lands on the static fader (`model_only` or a `FaderDb` the +/// curve overrides), if `S` leaves out the hand offset or adds it after the VCA, if the move takes +/// the VCA row's length, if a static fallback change commits live without being heard, or if the +/// in-place view reads the lane's committed offset instead of the edited one. +#[test] +fn a_hand_move_on_an_automated_fader_writes_the_lanes_offsets_cell() { + let session_ramps = LiveRamps { + fader_samples: 100, + mute_samples: 300, + pan_samples: 200, + link_samples: 0, + }; + let current = normalized(&edited(&with_vca(-2.0, -2.0, [false; 2]), |model| { + let ride = fader_ride(model); + model.automation.push(ride); + })); + let first = track_id(¤t, 0); + let rate = current.sample_rate_hz; + let fader = |lane| ControlAddress { + owner: Owner::Strip(current.tracks[0].id.clone()), + section: Section::Builtins, + control: 5, + lane, + }; + let mut ramp = EditRamps::new(); + ramp.set( + &EditRampTarget::Strip(current.tracks[0].id.clone()), + EditRampRow::Fader, + Some(256), + ); + let next = edited(¤t, |next| { + controls::set(next, &fader(ParameterChannel::Left), 3.0, Some(256), rate) + .expect("a hand move"); + }); + assert_eq!( + next.automation[0].offset.left_db.to_bits(), + 3.0_f32.to_bits() + ); + assert_eq!( + next.tracks[0].fader, current.tracks[0].fader, + "the fallback stays" + ); + let s = f64::from(3.0_f32) + f64::from(-2.0_f32); + let full = classify_live_delta(¤t, &next, session_ramps, &ramp); + assert_eq!( + full.as_ref().map(level), + Ok(vec![(first.clone(), Vec::new(), [sum(s, 256), None])]), + "one offsets-cell write, the edit's length" + ); + // An offset-only upsert of the ride is the same write, with the session's fader length. + assert_eq!( + classify_live_delta( + ¤t, + &edited(¤t, |next| next.automation[0].offset.right_db = -1.5), + session_ramps, + &EditRamps::new(), + ) + .map(|delta| level(&delta)), + Ok(vec![( + first.clone(), + Vec::new(), + [None, sum(f64::from(-1.5_f32) + f64::from(-2.0_f32), 100)] + )]) + ); + // The static fader of an automated lane is no control value: a change of it is structure. + assert_eq!( + classify_live_delta( + ¤t, + &edited(¤t, |next| next.tracks[0].fader.left_db = -9.0), + session_ramps, + &EditRamps::new(), + ) + .map(|delta| level(&delta)), + Err(LiveRebuild::Structure) + ); + + #[cfg(feature = "test-support")] + { + let rows = [fader(ParameterChannel::Left)]; + let full = full.map(|delta| level(&delta)); + let _ = host_core::take_fast_classification_count(); + let fast = host_core::classify_live_edit( + ¤t, + &next, + session_ramps, + &ramp, + host_core::MonitorBase::NONE, + Some(&rows), + ); + assert_eq!( + host_core::take_fast_classification_count(), + 1, + "the fast path" + ); + assert_eq!( + fast.map(|delta| level(&delta)), + full, + "the fast path, whole" + ); + // R17-1: the in-place owners hold the ride the move wrote, and the view reads it. + let mut owners = controls::OwnerEdits::new(¤t); + owners + .set(&fader(ParameterChannel::Left), 3.0, Some(256), rate) + .expect("an in-place hand move"); + let (owners, slots) = owners.finish(); + assert_eq!( + slots.automation.len(), + 1, + "the ride is an owner of the edit" + ); + let view = controls::ModelView::edited(¤t, &owners); + let mut dense = view.to_model(); + dense.revision = next.revision; + assert_eq!(dense, next, "the view is the edited model"); + let staged = host_core::classify_live_staged( + ¤t, + view, + session_ramps, + &ramp, + host_core::MonitorBase::NONE, + &rows, + ) + .expect("the fast path judges it"); + assert_eq!(Ok(level(&staged)), full, "the fast path, in place"); + } +} + /// #1260 gate 1. Red if a profile field that preparation reads is masked with its profile's ID. #[test] fn an_output_profile_channel_change_is_structural() { diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index a3a4df253..bc346de50 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -196,10 +196,15 @@ fn a_fader_automation_is_charged_to_the_graph_plan_rows_and_the_cap() { ); } + // The control catalog charges its own bytes: the automated lane's `fader_db` row carries + // `automated` (#1650 D4), a descriptor of its own, which the catalog's resources count. + let catalog = with.control_catalog.resources().retained_bytes + - base.control_catalog.resources().retained_bytes; assert_eq!( - with_retained - base_retained, + with_retained - base_retained - catalog, charge, - "the heap the automation retains is its charge: {base_retained} -> {with_retained}" + "the heap the automation retains is its charge: {base_retained} -> {with_retained}, \ + the catalog's own {catalog}" ); let total = admitted(&with.report); diff --git a/crates/protocol/src/controller/tests.rs b/crates/protocol/src/controller/tests.rs index 710f605dd..d0b5100f5 100644 --- a/crates/protocol/src/controller/tests.rs +++ b/crates/protocol/src/controller/tests.rs @@ -453,6 +453,7 @@ fn mock_controls() -> Vec { family: crate::ControlFamily::Effect, active: true, value: 0.25, + automated: false, }), }, ] diff --git a/crates/protocol/src/message_wire.rs b/crates/protocol/src/message_wire.rs index e6863b7b9..f83284c5a 100644 --- a/crates/protocol/src/message_wire.rs +++ b/crates/protocol/src/message_wire.rs @@ -796,8 +796,13 @@ pub struct ControlRow { pub family: ControlFamily, /// `false` for a row the owner's present shape does not use. pub active: bool, - /// The committed value. + /// The committed value. On an automated lane (`automated`) of the `fader_db` row it is the + /// lane's stored hand-move offset in dB, which the curve renders under (#1650 D3). pub value: f32, + /// Whether a rendered stored automation drives the row's lane (#1650 D4). A descriptor field: + /// an automation edit that changes it advances the structure epoch. Only an automatable row is + /// automated. + pub automated: bool, } /// One page of the control list, in strictly ascending handle order. @@ -2945,7 +2950,7 @@ fn control_entry_is_valid(limits: crate::ProtocolLimits, entry: &ControlEntry) - /// A control row's own rules, shared by both directions: finite numbers, an ordered range, /// choices exactly for an enumeration and holding its default and value, and the class, reason, /// issue and reopen condition present exactly together. A control is automatable only if it is a -/// live model value. +/// live model value, and automated only if it is automatable. fn control_row_is_valid(limits: crate::ProtocolLimits, row: &ControlRow) -> bool { let finite = row.minimum.is_finite() && row.maximum.is_finite() @@ -2996,6 +3001,7 @@ fn control_row_is_valid(limits: crate::ProtocolLimits, row: &ControlRow) -> bool || (row.class == ControlClass::Live && row.scope == ControlScope::Model && row.family != ControlFamily::Setting)) + && (!row.automated || row.automatable) } fn control_list_page_is_valid(limits: crate::ProtocolLimits, page: &ControlListPage) -> bool { @@ -3348,6 +3354,7 @@ fn write_control_entry(sink: &mut dyn Sink, entry: &ControlEntry) -> Result<(), (e::FAMILY, one(control)), (e::ACTIVE, one(control)), (e::VALUE, one(control)), + (e::AUTOMATED, one(control)), ])?; sink.nested_spec(control_list_page::ENTRY, &mut |sink| { sink.message_header(fields)?; @@ -3429,7 +3436,8 @@ fn write_control_row(sink: &mut dyn Sink, row: &ControlRow) -> Result<(), Encode write_spec!(sink, e::AUTOMATABLE, &[u8::from(row.automatable)])?; write_spec!(sink, e::FAMILY, &[row.family.code()])?; write_spec!(sink, e::ACTIVE, &[u8::from(row.active)])?; - write_spec!(sink, e::VALUE, &row.value.to_le_bytes()) + write_spec!(sink, e::VALUE, &row.value.to_le_bytes())?; + write_spec!(sink, e::AUTOMATED, &[u8::from(row.automated)]) } fn write_enum_choice(sink: &mut dyn Sink, choice: &EnumChoice) -> Result<(), EncodeError> { @@ -3565,6 +3573,7 @@ fn decode_control_entry( e::FAMILY, e::ACTIVE, e::VALUE, + e::AUTOMATED, ]; let body = match row_kind { ControlRowKind::OwnerHeader => { @@ -3654,6 +3663,7 @@ fn decode_control_entry( family: ControlFamily::from_code(read_u8(required(e::FAMILY)?)?)?, active: read_bool(required(e::ACTIVE)?)?, value: float(e::VALUE)?, + automated: read_bool(required(e::AUTOMATED)?)?, }) } }; diff --git a/crates/protocol/src/message_wire/tests.rs b/crates/protocol/src/message_wire/tests.rs index e328a53f8..e7ded93b8 100644 --- a/crates/protocol/src/message_wire/tests.rs +++ b/crates/protocol/src/message_wire/tests.rs @@ -735,6 +735,7 @@ fn control_row(name: &str) -> ControlRow { family: ControlFamily::Effect, active: true, value: 4.0, + automated: false, } } @@ -1368,6 +1369,11 @@ fn control_entry_rules_refuse_on_encode_and_decode_alike() { Some(|p| row(p, 6).automatable = true), |f| set(entry(f, 6), 31, &[1]), ), + ( + "automated row that is not automatable", + Some(|p| row(p, 6).automated = true), + |f| set(entry(f, 6), 35, &[1]), + ), ( "automatable row in the monitor scope", Some(|p| row(p, 4).scope = ControlScope::Monitor), diff --git a/crates/protocol/src/schema.rs b/crates/protocol/src/schema.rs index b8ade75be..a7b88e591 100644 --- a/crates/protocol/src/schema.rs +++ b/crates/protocol/src/schema.rs @@ -443,6 +443,8 @@ pub(crate) mod control_entry { pub(crate) const FAMILY: FieldSpec = FieldSpec::opt(32, Wire::U8); pub(crate) const ACTIVE: FieldSpec = FieldSpec::opt(33, Wire::Bool); pub(crate) const VALUE: FieldSpec = FieldSpec::opt(34, Wire::F32); + // #1650 D4: a rendered stored automation drives the row's lane. + pub(crate) const AUTOMATED: FieldSpec = FieldSpec::opt(35, Wire::Bool); pub(crate) static SPEC: MessageSpec = MessageSpec { name: "ControlEntry", fields: &[ @@ -480,6 +482,7 @@ pub(crate) mod control_entry { FAMILY, ACTIVE, VALUE, + AUTOMATED, ], }; } diff --git a/crates/session/src/in_place.rs b/crates/session/src/in_place.rs index 0d8cf5dfa..ca33934eb 100644 --- a/crates/session/src/in_place.rs +++ b/crates/session/src/in_place.rs @@ -3,13 +3,14 @@ //! (parameter entries only, #1620), and how many bytes they take in the canonical text. //! //! A host that changes the value of a control writes it into a **clone of the owner** (a track, a -//! submix, a route or a VCA), checks the clone with [`owners_values_valid`] and +//! submix, a route, a VCA, or the automation entry whose offset a hand move on an automated fader +//! writes, #1650), checks the clone with [`owners_values_valid`] and //! [`owners_growth`], measures the change of the canonical text with //! [`canonical_owner_bytes`], and installs the clones with [`CompiledSession::swap_owners`]. The //! rest of the compiled session is the same value, so nothing else is cloned, validated or written. use crate::canonical::owner_bytes; -use crate::{CompiledSession, Route, SessionModel, Submix, Track, Vca}; +use crate::{Automation, CompiledSession, Route, SessionModel, Submix, Track, Vca}; /// One owner of a session, borrowed. #[derive(Clone, Copy, Debug)] @@ -22,6 +23,9 @@ pub enum OwnerRef<'a> { Vca(&'a Vca), /// A route. Route(&'a Route), + /// A stored automation entry: a hand move on an automated fader writes its offset (#1650, + /// R17-1 = A). + Automation(&'a Automation), } /// Where the edited owners sit: for each, its index in the **owners** model (a small model that @@ -36,10 +40,14 @@ pub struct OwnerSlots { pub vcas: Vec<(usize, usize)>, /// The same for routes. pub routes: Vec<(usize, usize)>, + /// The same for automation entries (#1650, R17-1 = A): the entries whose offset a hand move + /// on an automated fader wrote. + pub automation: Vec<(usize, usize)>, } impl OwnerSlots { - /// The edited owners as held by `owners`, tracks, submixes, VCAs, then routes. + /// The edited owners as held by `owners`, tracks, submixes, VCAs, routes, then automation + /// entries. /// /// # Panics /// @@ -83,6 +91,11 @@ impl OwnerSlots { .iter() .map(move |slot| OwnerRef::Route(&model.routes[at(slot)])), ) + .chain( + self.automation + .iter() + .map(move |slot| OwnerRef::Automation(&model.automation[at(slot)])), + ) } /// Whether no owner is edited. @@ -92,6 +105,7 @@ impl OwnerSlots { && self.submixes.is_empty() && self.vcas.is_empty() && self.routes.is_empty() + && self.automation.is_empty() } } @@ -105,6 +119,7 @@ pub fn canonical_owner_bytes<'a>(owners: impl IntoIterator>) OwnerRef::Submix(submix) => owner_bytes(submix), OwnerRef::Vca(vca) => owner_bytes(vca), OwnerRef::Route(route) => owner_bytes(route), + OwnerRef::Automation(automation) => owner_bytes(automation), }) .sum() } @@ -210,6 +225,10 @@ fn grow(growth: &mut OwnersGrowth, old: OwnerRef<'_>, new: OwnerRef<'_>) -> Opti (old.members.len() == new.members.len()).then_some(()) } (OwnerRef::Route(_), OwnerRef::Route(_)) => Some(()), + // A hand move writes an entry's offset and no count (#1650). + (OwnerRef::Automation(old), OwnerRef::Automation(new)) => { + (old.segments.len() == new.segments.len()).then_some(()) + } _ => None, } } @@ -246,6 +265,10 @@ impl CompiledSession { debug_assert_eq!(model.routes[committed].id, owners.routes[at].id); core::mem::swap(&mut model.routes[committed], &mut owners.routes[at]); } + for &(at, committed) in &slots.automation { + debug_assert_eq!(model.automation[committed].id, owners.automation[at].id); + core::mem::swap(&mut model.automation[committed], &mut owners.automation[at]); + } } /// Finish a commit of values: record the revision the owners were committed at and the resource diff --git a/crates/session/src/validate.rs b/crates/session/src/validate.rs index 1d668dfc8..77f40b650 100644 --- a/crates/session/src/validate.rs +++ b/crates/session/src/validate.rs @@ -1315,6 +1315,11 @@ pub(crate) fn owner_values_valid(owner: crate::OwnerRef<'_>) -> bool { ), crate::OwnerRef::Vca(vca) => validate_vca_values(&mut diagnostics, &root, vca), crate::OwnerRef::Route(route) => validate_route_values(&mut diagnostics, &root, route), + // A hand move writes an entry's offset (#1650); the row's domain (`controls`) has checked + // it, and the session holds every number finite. + crate::OwnerRef::Automation(automation) => { + return automation.offset.left_db.is_finite() && automation.offset.right_db.is_finite(); + } } diagnostics.is_empty() } diff --git a/crates/session/tests/in_place.rs b/crates/session/tests/in_place.rs index 4bb3b25b0..7d734093f 100644 --- a/crates/session/tests/in_place.rs +++ b/crates/session/tests/in_place.rs @@ -87,6 +87,7 @@ fn slots(model: &SessionModel, edited: &SessionModel) -> (OwnerSlots, SessionMod submixes: vec![(0, 0)], vcas: vec![(0, 0)], routes: vec![(0, 0), (1, 1)], + automation: Vec::new(), }; (slots, owners) } diff --git a/docs/CONTROL_PROTOCOL_CONFORMANCE.md b/docs/CONTROL_PROTOCOL_CONFORMANCE.md index 2328ec729..0ef62b861 100644 --- a/docs/CONTROL_PROTOCOL_CONFORMANCE.md +++ b/docs/CONTROL_PROTOCOL_CONFORMANCE.md @@ -1,6 +1,6 @@ # MISO control protocol conformance record -This manifest records Issue 005 non-benchmark evidence and its limits. The normative corpus is `complete_schema_corpus()`: 46 canonical frames covering 11 commands (including one transaction with all 35 edit opcodes), 13 success responses, 16 non-OK statuses including typed backpressure, and six events. Its FNV-1a-64 label-and-byte hash is `5eb9ad2ed137f3be` (`COMPLETE_SCHEMA_HASH`; re-pinned from `0c7196f86abe6d57` for the automation offset in #1650: the `Automation` message gains a required field 4, the nested `AutomationOffset` (`left_db` 1, `right_db` 2), which the transaction's `UpsertAutomation` carries, 46 frames still; re-pinned once in batch 13b, from `cc9a7bcc502c4936`, for two changes together: the retirement of the stored transport position in #1635 (`TRANSPORT_SET` field 2, the snapshot's field 2 and the `TRANSPORT_STATE` event's field 3 are refused and never reallocated, so the command frame carries the state alone and the snapshot and event frames the state and the effective sample, 46 frames still) and the optional expected effect of the `SET_CONTROL` address in #1678 (the transaction appends a `SetControl` on an insert row that carries it); re-pinned for the fold of `OBSERVATION_ENDED` to one event per commit in #1584 D6a (root ruling A): the event carries a repeated key message, and the corpus frame carries two keys; re-pinned for `OBSERVATION_ENDED` in #1584: the corpus gains `event.observation_ended` (`8011`) and the capabilities response lists event `8011`; re-pinned for the retirement of `AUTOMATION_CANCELED` in #1628: the event `8002` is refused and never reallocated, so the corpus loses `event.automation_canceled` and the capabilities response lists no event `8002`; re-pinned for the retirement of `AUTOMATION_ENQUEUE` in #1627: `0006`, its success response, statuses 15 and 16 and capability fields 9, 12, 19 and 20 are refused and never reallocated, so the corpus loses the command frame, its response and the two status frames and the capabilities response has 23 fields; re-pinned for `RESPONSE_GET` in #1573: `000e`, one command frame and two response frames (a curve reply and a lone tag), and the capability registry lists `000e` with bit 14; re-pinned for the transaction response of the monitor edit path in the batch 4 review fold: the corpus gains `response.session_transaction_monitor`, `edit_path` 4; re-pinned for the retirement of the thirteen per-kind value ops in #1561: opcodes `0008`, `0203`, `020a`, `020b`, `020d`-`0210`, `0504`-`0507` and `0702` are refused and never reallocated, 35 edits; re-pinned for `TransactionApplied` field 2, `edit_path`, in #1313; re-pinned for `SetControl` in #1553: opcode `0800` appended, 48 edits; re-pinned for the control list in #1551: `CONTROL_LIST_GET` and `CONTROL_STATE_GET` (`000c`, `000d`) replace the retired `0004` and `0005`, with a list page of every kind of row and the state page of its control rows, re-pinned for the BLAKE3 source-identity spelling in #787, for decision 12's track encoding in #1093: field 7 carries the inserts, the retired fields 6 and 8 are gone, and field 11 carries the console entries, for decision 12's console edits in #1094: `SetConsole` `0007` and `SetTrackConsole` `0211`, for the submix strip in #1199: submix fields 2, 4, 5, 6, for the submix console in #1202: submix field 3 repeats the console entry, and for the bus tap in #1203: a tag-2 route source carries a required tap, and the corpus's `SetRouteSource` value is a tapped submix source, and for the route switch in #1216: route field `mute` (field 6) and opcode `0506` (`SetRouteMute`), and for the send's follow in #1218: route field `follows_mute` (field 7) and opcode `0507` (`SetRouteFollowsMute`), and for VCA groups in #1241: the VCA message (`id` 1, `fader` 2, repeated `members` 3) and opcodes `0700`-`0702` (`UpsertVca`, `RemoveVca`, `SetVcaFader`), and for the per-edit ramp length in #1394: #1394 appends optional `ramp_samples` to the live-value edits, and the corpus's `SetTrackFader` carries one, and for the session's control smoothing in #1365: #1365 appends `SetControlSmoothing` `0008`, and the corpus's transaction carries one). Decision 12's retired and refused codes each have a wire row in `retired_code_rows()`, and so do the retired message IDs `0004`, `0005`, `0006` and `8002` (#1551, #1627, #1628; `0006` is a frame with the payload the command had, `8002` an event ID under a command header: the decoder's `UNSUPPORTED_MESSAGE`, with no response frame because a message ID that decodes to no message has no safe request identity): rack codes 1 and 3 in every rack-addressed edit and in an automation target, track fields 6 and 8 in both flag forms, `TRANSPORT_SET` field 2 in both flag forms (#1635), each structural or declaration edit addressed at `console`, each of the thirteen value-op codes retired by #1561 (an unallocated opcode, refused at decode), and a `SetTrackConsole` in a transaction with no `SetConsole` before it that changes the console (`session.edit.value_op_retired`); an endpoint over `console_session_fixture()` answers each with its status and commits nothing. Auditable seed frames are checked in under [`fuzz/corpus`](../fuzz/corpus/README.md). +This manifest records Issue 005 non-benchmark evidence and its limits. The normative corpus is `complete_schema_corpus()`: 46 canonical frames covering 11 commands (including one transaction with all 35 edit opcodes), 13 success responses, 16 non-OK statuses including typed backpressure, and six events. Its FNV-1a-64 label-and-byte hash is `5cab2cf3b4e4767f` (`COMPLETE_SCHEMA_HASH`; re-pinned from `5eb9ad2ed137f3be` for the control row's `automated` flag in #1650b: `ControlEntry` field 35, written on every control row, and the list page's live `ratio` row is automated, 46 frames still; re-pinned from `0c7196f86abe6d57` for the automation offset in #1650: the `Automation` message gains a required field 4, the nested `AutomationOffset` (`left_db` 1, `right_db` 2), which the transaction's `UpsertAutomation` carries, 46 frames still; re-pinned once in batch 13b, from `cc9a7bcc502c4936`, for two changes together: the retirement of the stored transport position in #1635 (`TRANSPORT_SET` field 2, the snapshot's field 2 and the `TRANSPORT_STATE` event's field 3 are refused and never reallocated, so the command frame carries the state alone and the snapshot and event frames the state and the effective sample, 46 frames still) and the optional expected effect of the `SET_CONTROL` address in #1678 (the transaction appends a `SetControl` on an insert row that carries it); re-pinned for the fold of `OBSERVATION_ENDED` to one event per commit in #1584 D6a (root ruling A): the event carries a repeated key message, and the corpus frame carries two keys; re-pinned for `OBSERVATION_ENDED` in #1584: the corpus gains `event.observation_ended` (`8011`) and the capabilities response lists event `8011`; re-pinned for the retirement of `AUTOMATION_CANCELED` in #1628: the event `8002` is refused and never reallocated, so the corpus loses `event.automation_canceled` and the capabilities response lists no event `8002`; re-pinned for the retirement of `AUTOMATION_ENQUEUE` in #1627: `0006`, its success response, statuses 15 and 16 and capability fields 9, 12, 19 and 20 are refused and never reallocated, so the corpus loses the command frame, its response and the two status frames and the capabilities response has 23 fields; re-pinned for `RESPONSE_GET` in #1573: `000e`, one command frame and two response frames (a curve reply and a lone tag), and the capability registry lists `000e` with bit 14; re-pinned for the transaction response of the monitor edit path in the batch 4 review fold: the corpus gains `response.session_transaction_monitor`, `edit_path` 4; re-pinned for the retirement of the thirteen per-kind value ops in #1561: opcodes `0008`, `0203`, `020a`, `020b`, `020d`-`0210`, `0504`-`0507` and `0702` are refused and never reallocated, 35 edits; re-pinned for `TransactionApplied` field 2, `edit_path`, in #1313; re-pinned for `SetControl` in #1553: opcode `0800` appended, 48 edits; re-pinned for the control list in #1551: `CONTROL_LIST_GET` and `CONTROL_STATE_GET` (`000c`, `000d`) replace the retired `0004` and `0005`, with a list page of every kind of row and the state page of its control rows, re-pinned for the BLAKE3 source-identity spelling in #787, for decision 12's track encoding in #1093: field 7 carries the inserts, the retired fields 6 and 8 are gone, and field 11 carries the console entries, for decision 12's console edits in #1094: `SetConsole` `0007` and `SetTrackConsole` `0211`, for the submix strip in #1199: submix fields 2, 4, 5, 6, for the submix console in #1202: submix field 3 repeats the console entry, and for the bus tap in #1203: a tag-2 route source carries a required tap, and the corpus's `SetRouteSource` value is a tapped submix source, and for the route switch in #1216: route field `mute` (field 6) and opcode `0506` (`SetRouteMute`), and for the send's follow in #1218: route field `follows_mute` (field 7) and opcode `0507` (`SetRouteFollowsMute`), and for VCA groups in #1241: the VCA message (`id` 1, `fader` 2, repeated `members` 3) and opcodes `0700`-`0702` (`UpsertVca`, `RemoveVca`, `SetVcaFader`), and for the per-edit ramp length in #1394: #1394 appends optional `ramp_samples` to the live-value edits, and the corpus's `SetTrackFader` carries one, and for the session's control smoothing in #1365: #1365 appends `SetControlSmoothing` `0008`, and the corpus's transaction carries one). Decision 12's retired and refused codes each have a wire row in `retired_code_rows()`, and so do the retired message IDs `0004`, `0005`, `0006` and `8002` (#1551, #1627, #1628; `0006` is a frame with the payload the command had, `8002` an event ID under a command header: the decoder's `UNSUPPORTED_MESSAGE`, with no response frame because a message ID that decodes to no message has no safe request identity): rack codes 1 and 3 in every rack-addressed edit and in an automation target, track fields 6 and 8 in both flag forms, `TRANSPORT_SET` field 2 in both flag forms (#1635), each structural or declaration edit addressed at `console`, each of the thirteen value-op codes retired by #1561 (an unallocated opcode, refused at decode), and a `SetTrackConsole` in a transaction with no `SetConsole` before it that changes the console (`session.edit.value_op_retired`); an endpoint over `console_session_fixture()` answers each with its status and commits nothing. Auditable seed frames are checked in under [`fuzz/corpus`](../fuzz/corpus/README.md). | Evidence | Recorded result | Limitation | | --- | --- | --- | diff --git a/docs/CONTROL_PROTOCOL_REGISTRY.md b/docs/CONTROL_PROTOCOL_REGISTRY.md index 930b9db72..62d0c6d49 100644 --- a/docs/CONTROL_PROTOCOL_REGISTRY.md +++ b/docs/CONTROL_PROTOCOL_REGISTRY.md @@ -54,9 +54,9 @@ Snapshot continuations and diagnostic pages use an exact revision after an any-r **Address** (`ControlAddress`): `1:owner-kind U8 R` (session `1`, strip `2`, route `3`, VCA `4`), `2:owner-id UTF8 O` (absent exactly for the session), `3:section U8 R` (inserts `2`, builtins `4`, console `5`, own `6`; the first three are the rack codes above), `4:slot-id UTF8 O` (present exactly for console and inserts), `5:control U32 O`, `6:lane U8 O` (left `1`, right `2`, both `3`; `5` and `6` are present exactly in a control row). An owner header carries the `own` section. -**Entry** (`ControlEntry`): `1:handle U32 R`, `2:row-kind U8 R` (owner header `1`, section header `2`, control `3`), `3:address MESSAGE R`. A header row adds `4:header-kind U8` (owner: track `1`, submix `2`, route `3`, VCA `4`, session `5`, and it must agree with the address's owner kind; section: the section code), `5:name UTF8`, `6:effect UTF8 O` (a console or inserts section header only), and for a route owner header `7:source UTF8`, `8:destination UTF8`, `9:tap U8` (the route tap codes below). A control row adds `10:name`, `11:display-name`, `12:display-unit` (UTF8), `13:unit U8` (dB/Hz/ms/samples/linear/ratio `1..6`), `14:kind U8` (continuous `1`, toggle `2`, enumeration `3`), `15:minimum`, `16:maximum`, `17:disabled-value O`, `18:default` (F32), `19:taper U8` (linear `1`, logarithmic `2`, exponential `3`, decibel `4`, stepped `5`), `20:step F32`, `21:precision U8`, `22:ladder PACKED_F32`, `23:choice MESSAGE O*` (value `1:F32`, label `2:UTF8`), `24:ramp-row U8 O` (the live-ramp rows in declaration order: fader `1`, input trim `2`, send gain `3`, VCA offset `4`, link glide `5`, mute `6`, solo `7`, polarity invert `8`, send mute `9`, send follows-mute `10`, VCA mute `11`, bypass crossfade `12`, pan `13`, matrix `14`, send matrix `15`; absent means a fixed ramp), `25:default-ramp-samples U32`, `26:class U8` (live `1`, rebuild-only `2`, derived `3`), `27:reason U8 O` (new memory `1`, new graph `2`, processing order `3`, latency `4`, glitch `5`, optimisation `6`, correctness `7`, ruled live not built `8`), `28:reason-issue U32 O`, `29:reopen UTF8 O`, `30:scope U8` (model `1`, monitor `2`), `31:automatable BOOL`, `32:family U8` (strip level `1`, strip matrix `2`, strip input `3`, route `4`, effect `5`, setting `6`), `33:active BOOL`, `34:value F32`. Header-only and control-only fields are optional on the wire and required or refused by the row kind: a header with a control field, a control row with a header field, a missing required field or a code outside its table is `INVALID_FIELD`. +**Entry** (`ControlEntry`): `1:handle U32 R`, `2:row-kind U8 R` (owner header `1`, section header `2`, control `3`), `3:address MESSAGE R`. A header row adds `4:header-kind U8` (owner: track `1`, submix `2`, route `3`, VCA `4`, session `5`, and it must agree with the address's owner kind; section: the section code), `5:name UTF8`, `6:effect UTF8 O` (a console or inserts section header only), and for a route owner header `7:source UTF8`, `8:destination UTF8`, `9:tap U8` (the route tap codes below). A control row adds `10:name`, `11:display-name`, `12:display-unit` (UTF8), `13:unit U8` (dB/Hz/ms/samples/linear/ratio `1..6`), `14:kind U8` (continuous `1`, toggle `2`, enumeration `3`), `15:minimum`, `16:maximum`, `17:disabled-value O`, `18:default` (F32), `19:taper U8` (linear `1`, logarithmic `2`, exponential `3`, decibel `4`, stepped `5`), `20:step F32`, `21:precision U8`, `22:ladder PACKED_F32`, `23:choice MESSAGE O*` (value `1:F32`, label `2:UTF8`), `24:ramp-row U8 O` (the live-ramp rows in declaration order: fader `1`, input trim `2`, send gain `3`, VCA offset `4`, link glide `5`, mute `6`, solo `7`, polarity invert `8`, send mute `9`, send follows-mute `10`, VCA mute `11`, bypass crossfade `12`, pan `13`, matrix `14`, send matrix `15`; absent means a fixed ramp), `25:default-ramp-samples U32`, `26:class U8` (live `1`, rebuild-only `2`, derived `3`), `27:reason U8 O` (new memory `1`, new graph `2`, processing order `3`, latency `4`, glitch `5`, optimisation `6`, correctness `7`, ruled live not built `8`), `28:reason-issue U32 O`, `29:reopen UTF8 O`, `30:scope U8` (model `1`, monitor `2`), `31:automatable BOOL`, `32:family U8` (strip level `1`, strip matrix `2`, strip input `3`, route `4`, effect `5`, setting `6`), `33:active BOOL`, `34:value F32`, `35:automated BOOL` (#1650 D4: a rendered stored automation drives the row's lane; on such a lane of a strip's builtin `fader_db` row, `value` is the lane's stored hand-move offset in dB, which a `SET_CONTROL` of the row writes and the curve renders under). Header-only and control-only fields are optional on the wire and required or refused by the row kind: a header with a control field, a control row with a header field, a missing required field or a code outside its table is `INVALID_FIELD`. -A control row's own rules: all numbers are finite and `minimum <= maximum`; choices are present exactly for an enumeration, unique, and hold the default and the value; `reason` and `reopen` are present exactly when the class is rebuild-only, and `reason-issue` exactly with reason `8`; a control is automatable only if it is live, in the model scope and not a setting, and the wire check is that implication only: a live row that a ruling excludes from automation is a valid row with `automatable` false. Two rows are excluded (#1632, decision 17 C3): a send's `follows_mute` ("a routing setting, not a performance value") and a strip's `matrix_mode` ("a mode, not a performance value"); an owner request reopens either. +A control row's own rules: all numbers are finite and `minimum <= maximum`; choices are present exactly for an enumeration, unique, and hold the default and the value; `reason` and `reopen` are present exactly when the class is rebuild-only, and `reason-issue` exactly with reason `8`; a control is automatable only if it is live, in the model scope and not a setting, and the wire check is that implication only: a live row that a ruling excludes from automation is a valid row with `automatable` false. Two rows are excluded (#1632, decision 17 C3): a send's `follows_mute` ("a routing setting, not a performance value") and a strip's `matrix_mode` ("a mode, not a performance value"); an owner request reopens either. A control is automated only if it is automatable; `automated` is a descriptor field, so an automation edit that changes it advances the structure epoch. **Pages.** The list page is `1:revision U64`, `2:structure-epoch U64`, `3:last-handle U32` (the last row's handle, or the request's cursor on an empty page), `4:eof BOOL`, `5:ControlEntry MESSAGE R*` (at most 256, ascending). The state page has the same first four fields, then count `5:U16`, stride `6:U16=8` and records `7:BYTES`: `{handle:u32, value:f32}` per control row of the covered handles, ascending; `last-handle` is the cursor for the next request and may follow the last record (a header row is covered and has no record). A record array whose width, count, order or values break these rules is refused. Handles are revision-scoped; a record's state flags no longer exist, because a value is always valid. diff --git a/docs/CONTROL_PROTOCOL_SEMANTICS.md b/docs/CONTROL_PROTOCOL_SEMANTICS.md index da20a6db2..b24f4506a 100644 --- a/docs/CONTROL_PROTOCOL_SEMANTICS.md +++ b/docs/CONTROL_PROTOCOL_SEMANTICS.md @@ -18,7 +18,7 @@ All v1 operations are absolute and idempotent. A new logical command has a nonze There is one way to make a change at a sample time: a stored automation edit (`SessionEdit` opcodes `0600`-`0603`), which the engine renders from the session's stored automation table (#1058). The command `AUTOMATION_ENQUEUE` (`0006`), which once admitted a batch of timed records to a queue, is **retired** (#1627, under the owner ruling in `rulings/engine-footprint-2026-09-28.md`: sample-timed delivery had no product consumer, and a host that admitted a batch acked work that no PCM reached). A frame under message ID `0006` is refused at decode as `UNSUPPORTED_MESSAGE`, before the revision check, so no revision, event, queue or replay entry changes and no ack can precede a drop; no endpoint lists `0006`, flag bit 5 or the capability fields that described its admission (9, 12, 19 and 20), and statuses 15 (`TIME_IN_PAST`) and 16 (`AUTOMATION_ORDER`) are retired with it. Every retired code is refused, never reallocated and never renumbered (see the [registry](CONTROL_PROTOCOL_REGISTRY.md)). The `Step`, `Linear` and `Exponential` span kinds and the `Sample` automation rate are retired with it (#1636, `EFFECT_CONTRACT_V1.md`, "V1 runtime automation"). -**Delivery history (v1).** The delivery of the command was planned by #140, descoped by the owner ruling in `rulings/engine-footprint-2026-09-28.md`, and its unwired partial implementation (the #460 delivery service, its controller facade and the host-core builtin-batch and scalar-point endpoints) was deleted by #1056. #1315 then refused the command `UNAVAILABLE` on both hosts behind a provider feature switch that nothing set; #1627 retired the command and removed the switch. Two outcomes have their own owners instead. Rendering the session's stored automation table is #1058. Live value edits on the C ABI are delivered by #1053: a committed `SESSION_TRANSACTION_APPLY` whose delta is only live values is lowered onto the running plan's live-control lanes, with the same response and events as before. The live values are a track's fader, mute and pan or matrix values (#1257); a changed value of a track effect parameter whose `automation_rate` is `Block`, on a console-slot entry or an insert (#1264), the parametric EQ's live parameters included, which ride prepared targets (#1265); and a track effect's bypass, except the delay's and the multiband compressor's, which rebuild (#1266). A transaction that changes only the session ID, a profile's id or a stored automation entry on a row that does not render yet commits with no replacement and no live value (#1260). A stored fader automation edit (an entry on a strip's `fader_db` added, removed, retargeted or given other segments) is a rebuild that carries every owner (#1648); a static fader value of a lane the session automates is that lane's fallback and gives no live record (#1648 D3); every other row stays model-only until its rendering slice adds it. Every other transaction still replaces the plan, including every submix strip value (#1053 G1), any edit while a VCA is declared (G3) and a mute change on a track that a `follows_mute` route reads (G2) (`C_ABI_V1_QUALIFICATION.md`, "Value-only track edits on the running plan" and the sections after it). +**Delivery history (v1).** The delivery of the command was planned by #140, descoped by the owner ruling in `rulings/engine-footprint-2026-09-28.md`, and its unwired partial implementation (the #460 delivery service, its controller facade and the host-core builtin-batch and scalar-point endpoints) was deleted by #1056. #1315 then refused the command `UNAVAILABLE` on both hosts behind a provider feature switch that nothing set; #1627 retired the command and removed the switch. Two outcomes have their own owners instead. Rendering the session's stored automation table is #1058. Live value edits on the C ABI are delivered by #1053: a committed `SESSION_TRANSACTION_APPLY` whose delta is only live values is lowered onto the running plan's live-control lanes, with the same response and events as before. The live values are a track's fader, mute and pan or matrix values (#1257); a changed value of a track effect parameter whose `automation_rate` is `Block`, on a console-slot entry or an insert (#1264), the parametric EQ's live parameters included, which ride prepared targets (#1265); and a track effect's bypass, except the delay's and the multiband compressor's, which rebuild (#1266). A transaction that changes only the session ID, a profile's id or a stored automation entry on a row that does not render yet commits with no replacement and no live value (#1260). A stored fader automation edit (an entry on a strip's `fader_db` added, removed, retargeted or given other segments) is a rebuild that carries every owner (#1648); a static fader value of a lane the session automates is that lane's fallback and gives no live record (#1648 D3), and a `SET_CONTROL fader_db` on that lane writes the entry's hand offset, a live value heard on top of the curve (#1650); every other row stays model-only until its rendering slice adds it. Every other transaction still replaces the plan, including every submix strip value (#1053 G1), any edit while a VCA is declared (G3) and a mute change on a track that a `follows_mute` route reads (G2) (`C_ABI_V1_QUALIFICATION.md`, "Value-only track edits on the running plan" and the sections after it). **Sources across a structural transaction.** A committed transaction that replaces the plan does not reset the sources it leaves unchanged. On the C ABI (#1273) such a source keeps its ring, generation and read position across the plan swap; source submissions and seeks address the newest committed session from the commit on, so a host keeps feeding the source contiguously and must not reseek it. PCM is never part of the protocol, so the transaction acknowledges no PCM: PCM already accepted for a source the transaction removed is discarded with its plan, and later submissions for it are refused as `source.id.unknown`. An added source starts at generation 1, frame 0, until the host seeks it; a source whose declaration the transaction changed restarts the same way, in a new ring at generation 1, frame 0, with the PCM accepted for it before the commit discarded with its plan, so the host restarts its feed. diff --git a/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index ae2ecbe2c..9e7741218 100644 --- a/docs/C_ABI_V1_QUALIFICATION.md +++ b/docs/C_ABI_V1_QUALIFICATION.md @@ -636,6 +636,12 @@ model's delta (`host_core::classify_live_delta`, #1053 D1), never by its opcodes lane the session automates is that lane's fallback: it gives no `FaderDb` record, whether an own edit or a VCA move reaches it, and its mute records are unchanged. Every other row stays model-only until its rendering slice adds it. +- **A hand move on an automated fader (#1650b).** On a lane a rendered `fader_db` entry drives, + `SET_CONTROL fader_db` writes the entry's hand offset, not the static fader: it is live (in place + when it is the transaction's only kind of edit), writes the lane's offsets cell with + `S = offset + the reaching VCA offsets` over the `Fader` row's length, and the list reports the + offset as the row's value with `automated` (field 35). An offset-only `UpsertAutomation` is the + same live write. A change of an automated lane's static fader is structure (a rebuild). - **Live effect parameters (#1264, #1265).** A track's console-slot entry or insert whose `params` change only in live parameters, parametric EQ values included, is live too; see "Value-only effect parameter edits" and "Value-only parametric EQ edits" below. So is a diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index e8ccec79d..80eae9bd0 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -336,13 +336,17 @@ each lane at `$.automation[id=].offset.left_db` or `.right_db`, after the se The offset of a lane belongs to the entry that drives the lane, so a lane-granular hand move on a `both` entry needs no entry split. `RemoveAutomation` removes the offset with the entry; `SetAutomationSegments` and `SetAutomationTarget` keep it, and the row rule judges it against the -new target. The offset is stored, checked and round-tripped (canonical JSON, wire, SDK). It is not -yet heard: render composes a fader event with the sum `S` of the reaching VCA offsets, and the -slice that adds the lane's own offset to `S` and routes a `SET_CONTROL fader_db` on an automated -lane to it (#1650b) lands in the same push as the fader automation render (#1646 to #1649). After -that slice the static fader value of an automated lane is not a control value: `SET_CONTROL` -writes the offset, and the static fallback changes only through a rebuild (remove the automation, -set the fader, upsert the automation, in one transaction) or a document replacement. +new target. The offset is stored, checked, round-tripped (canonical JSON, wire, SDK) and heard +(#1650b): render composes each fader event of the lane as `vca_compose_db(curve, S)`, where `S` +is the `f64` sum of the lane's own offset first and then the reaching VCA offsets in ascending VCA +ID. On a lane a rendered `fader_db` entry drives, the `fader_db` row reads and writes the offset: +`SET_CONTROL fader_db` sets it (live, one offsets-cell write; a `both` write sets both lanes), +`CONTROL_STATE_GET` and the control list report it as the row's value, and the list marks the row +`automated` (`ControlEntry` field 35). The static fader value of an automated lane is its fallback, +not a control value: the static fallback changes only through a rebuild (remove the automation, +set the fader, upsert the automation, in one transaction) or a document replacement, and any edit +that changes it on an automated lane is a change of structure. An `UpsertAutomation` that changes +only an entry's offset is live, as a hand move is. **The input filter order, on curves (#1645).** On each strip lane where HPF (3) or LPF (4) is automated (a `both` entry drives both lanes), each side is its entry under the hold rule, or the diff --git a/fuzz/corpus/complete-schema-manifest.md b/fuzz/corpus/complete-schema-manifest.md index a4c20a01b..52afff319 100644 --- a/fuzz/corpus/complete-schema-manifest.md +++ b/fuzz/corpus/complete-schema-manifest.md @@ -7,7 +7,7 @@ schemas. Optional/boundary values are represented by the transaction's nested fi transport-event origin, empty valid pages, and the typed backpressure variant. The canonical sequence is FNV-1a-64 over each stable frame label followed by its frame bytes: -`5eb9ad2ed137f3be`, pinned once as `COMPLETE_SCHEMA_HASH`. Native mutation, each typed fuzz +`5cab2cf3b4e4767f`, pinned once as `COMPLETE_SCHEMA_HASH`. Native mutation, each typed fuzz decoder, and scalar/simd128 Wasm execution all consume this same public fixture source. Issue #787 re-pinned this value after the transaction's two same-length source-identity spellings diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index ca5b5148d..ce09c8285 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -10377,7 +10377,8 @@ mod canonical_live { } /// TBD48 D2 (browser): a live fader move on a VCA member, a live VCA offset move over it, - /// and (#1649) that VCA move over a member whose fader is automated, each made through the browser's control entry while the caller's thread runs FTZ, DAZ and + /// (#1649) that VCA move over a member whose fader is automated, and (#1650b) a hand move on + /// that automated fader, each made through the browser's control entry while the caller's thread runs FTZ, DAZ and /// round toward zero, render from the end of their ramp the bits of the committed model booted /// on a canonical thread. The pair's composition rounds to another `f32`, and another gain, /// under that word (asserted), so the gate is not vacuous. @@ -10420,6 +10421,21 @@ mod canonical_live { &vca_move, &automated(model(MEMBER_DB, OFFSET_DB)), ); + // #1650b: a hand move on that automated fader writes the ride's left offset, and the + // lane's offsets cell with `S` = the offset and the VCA's; the static fader stays. + let mut moved = automated(model(MEMBER_DB, OFFSET_DB)); + moved.automation[0].offset.left_db = MEMBER_DB; + live_move_under_hostile_word( + "a hand move on an automated lane", + &automated(model(MEMBER_DB, OFFSET_DB)), + &set( + Owner::Strip(model(0.0, 0.0).tracks[MEMBER].id.clone()), + Section::Builtins, + 5, + MEMBER_DB, + ), + &moved, + ); } /// `model` with track [`MEMBER`]'s left fader automated, flat at [`MEMBER_DB`] (#1649). diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index c32dafa4e..ef2d3e135 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -4647,3 +4647,414 @@ fn vca_reach_and_count_changes_over_an_automated_member_are_live_alike_on_both_h } } } + +/// #1650b: `SET_CONTROL` of `t2`'s `fader_db` on `lane` (a hand move on its fader). +#[cfg(feature = "test-support")] +fn t2_fader(lane: ParameterChannel, db: f32) -> SessionEdit { + SessionEdit::SetControl { + expected_effect: None, + address: address(Owner::Strip(id(T2.id)), Section::Builtins, FADER_DB, lane), + value: db, + ramp_samples: None, + } +} + +/// One `fader_db` row of `t2` as the control list gives it (#1650 D4): its lane, the bits of its +/// value and its `automated` flag. +type FaderRow = (Option, u32, bool); + +/// The control list's structure epoch and `t2`'s two `fader_db` rows, paged over one host's command +/// entry. +fn t2_fader_rows( + mut send: impl FnMut(CommandPayload<'_>, ExpectedRevision) -> Vec, +) -> (u64, Vec) { + let mut rows = Vec::new(); + let (mut epoch, mut after) = (0, 0); + loop { + let request = ControlListRequest { + structure_epoch: epoch, + after_handle: after, + limit: 256, + owner: None, + }; + let bytes = send( + CommandPayload::ControlListGet(&request), + ExpectedRevision::Any, + ); + let mut fields = [0_u16; 4096]; + let DecodedTypedResponseFrame::Success { + payload: DecodedSuccessResponsePayload::ControlListPage(page), + .. + } = ProtocolCodec::default() + .decode_typed_response(&bytes, &mut DecodeScratch::new(&mut fields)) + .expect("a control page") + else { + panic!("expected a control list page") + }; + rows.extend(page.entries.iter().filter_map(|entry| match &entry.body { + protocol::ControlBody::Control(row) + if row.name == "fader_db" && entry.address.owner_id.as_deref() == Some(T2.id) => + { + Some((entry.address.lane, row.value.to_bits(), row.automated)) + } + _ => None, + })); + if page.eof { + return (page.structure_epoch, rows); + } + epoch = page.structure_epoch; + after = page.last_handle; + } +} + +/// The diagnostic codes of a control list request at `epoch`, or `None` when the host answers it +/// with a page. +fn list_refusal( + mut send: impl FnMut(CommandPayload<'_>, ExpectedRevision) -> Vec, + epoch: u64, +) -> Option> { + let request = ControlListRequest { + structure_epoch: epoch, + after_handle: 0, + limit: 1, + owner: None, + }; + let bytes = send( + CommandPayload::ControlListGet(&request), + ExpectedRevision::Any, + ); + let mut fields = [0_u16; 4096]; + match ProtocolCodec::default() + .decode_typed_response(&bytes, &mut DecodeScratch::new(&mut fields)) + .expect("a response") + { + DecodedTypedResponseFrame::Success { .. } => None, + DecodedTypedResponseFrame::NonOk { payload, .. } => Some( + payload + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code.clone()) + .collect(), + ), + } +} + +/// What one host pair gives for one #1650b hand move: the one reply, the committed document, the +/// list after the move and every block from the edit on (equal on both hosts). +#[cfg(feature = "test-support")] +struct HandMove { + reply: Vec, + committed: Vec, + rows: (u64, Vec), + blocks: Vec>, +} + +/// `edits` on `model`, after four blocks, as one transaction on both hosts: the same verdict +/// (`live`, one revision) and reply on both, and the same bits of every block up to `end`. +#[cfg(feature = "test-support")] +fn hand_move_alike(label: &str, model: &SessionModel, edits: &[SessionEdit], end: u64) -> HandMove { + const EDIT: u64 = 4; + let mut browser = Rig::new(model); + let mut c_abi = CAbi::new(model); + same_blocks(&mut browser, &mut c_abi, 0..EDIT, label); + let (b, reply) = browser.transaction_reply(edits); + let (c, c_reply) = c_abi.transaction(edits); + assert_eq!(b, c, "{label}: one verdict"); + assert_eq!(reply, c_reply, "{label}: one reply"); + assert_eq!( + b, + Verdict::Applied { + path: EditPath::Live, + revision: model.revision + 1 + }, + "{label}: a hand move is live" + ); + let rows = t2_fader_rows(|payload, expected| browser.command(payload, expected)); + assert_eq!( + rows, + t2_fader_rows(|payload, expected| c_abi.send(payload, expected)), + "{label}: one list" + ); + let committed = snapshot_bytes(|payload, expected| browser.command(payload, expected)); + let mut blocks = Vec::new(); + for block in EDIT..end { + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{label}: block {block} alike on both hosts"); + blocks.push(b); + } + HandMove { + reply, + committed, + rows, + blocks, + } +} + +/// #1650b gates 2 and 5 (both hosts) and root's ruling R17-1. `t2` rides its fader on both lanes +/// (and VCA `v` reaches it). A hand move of `+3` dB on its left lane is `live` alike on both hosts, +/// one revision on the same plan: the committed document stores `+3` as the ride's left offset and +/// keeps the static fader; from the end of the jump the output is, bit for bit, a boot of that +/// document; the list shows both of `t2`'s fader rows `automated`, the left one at `+3` and the +/// right one at `0`, at the same structure epoch. The same transaction prepared by the full path +/// (`control_plane::test_force_full_preparation`) gives the same reply and the same bits of every +/// block, and the in-place leg commits in place on both hosts. +/// +/// Test value: red if a hand move on an automated lane writes the static fallback (the curve then +/// overrides it, so the window differs from the boot), rebuilds, reaches the other lane, or if the +/// in-place path (which holds no automation entry unless the entry is an owner of the edit, R17-1) +/// answers or renders otherwise than the full path. +#[cfg(feature = "test-support")] +#[test] +fn a_hand_move_on_an_automated_fader_is_an_offset_alike_on_both_hosts() { + for rate in RATES { + let model = automated_vca_model(rate, |_| {}); + let mut expected = automated_vca_model(rate, |model| { + model.automation[0].offset.left_db = 3.0; + }); + expected.revision = model.revision + 1; + let edits = [t2_fader(ParameterChannel::Left, 3.0)]; + let label = format!("{rate} Hz"); + let before = t2_fader_rows(|payload, expected| Rig::new(&model).command(payload, expected)); + assert_eq!( + before.1, + vec![ + ( + Some(protocol::ParameterChannel::Left), + 0.0_f32.to_bits(), + true + ), + ( + Some(protocol::ParameterChannel::Right), + 0.0_f32.to_bits(), + true + ), + ], + "{label}: an automated lane lists its offset, 0" + ); + let mut probe = Rig::new(&model); + let settle = 4 + settle_blocks(&mut probe, model.control_smoothing_samples().fader); + let end = settle + 4; + assert!(end <= BLOCKS, "{label}: the run fits the sources"); + + control_plane::test_take_in_place_counts(); + let in_place = hand_move_alike(&format!("{label}, in place"), &model, &edits, end); + assert_eq!( + control_plane::test_take_in_place_counts(), + (2, 0), + "{label}: both hosts commit the hand move in place" + ); + assert_eq!( + in_place.committed, + canonical_session_json(&expected) + .expect("canonical") + .as_bytes(), + "{label}: the ride stores +3 on its left lane and the static fader stays" + ); + assert_eq!( + in_place.rows, + ( + before.0, + vec![ + ( + Some(protocol::ParameterChannel::Left), + 3.0_f32.to_bits(), + true + ), + ( + Some(protocol::ParameterChannel::Right), + 0.0_f32.to_bits(), + true + ), + ] + ), + "{label}: the list reads the offset back, at the same epoch" + ); + let mut twin = Rig::new(&expected); + let mut unedited = Rig::new(&model); + let mut moved = false; + for block in 0..end { + let (t, u) = (twin.step(block), unedited.step(block)); + if block >= settle { + let at = (block - 4) as usize; + assert_eq!( + in_place.blocks[at], t, + "{label}: block {block} renders the boot of the stored offset" + ); + moved |= t != u; + } + } + assert!(moved, "{label}: the offset is heard"); + + control_plane::test_force_full_preparation(true); + let full = hand_move_alike(&format!("{label}, full path"), &model, &edits, end); + control_plane::test_force_full_preparation(false); + assert_eq!( + control_plane::test_take_in_place_counts().0, + 0, + "{label}: the full leg commits nothing in place" + ); + assert_eq!( + full.reply, in_place.reply, + "{label}: one reply on both paths" + ); + assert_eq!(full.committed, in_place.committed, "{label}: one document"); + assert_eq!(full.rows, in_place.rows, "{label}: one list"); + assert_eq!( + full.blocks, in_place.blocks, + "{label}: the same bits on both paths" + ); + } +} + +/// #1650b gates 4 and 5 (both hosts). On a ride that stores a `+3` dB left offset, +/// `SetAutomationSegments` (a new curve) rebuilds and keeps the offset: the list still reads `+3`, +/// `automated`, and the document keeps it. `RemoveAutomation` rebuilds and removes it with the +/// entry: the lanes return to their static fader, which the list then reads, no longer +/// `automated`, under a new structure epoch, so a list request at the epoch the client held is +/// refused `control.list.stale`. Both hosts answer every step alike and render the same bits. +/// +/// Test value: red if a curve edit drops the hand offset (the product reason for owner answer C), +/// if a removed entry leaves its offset or its `automated` flag, or if a client can keep paging a +/// list whose rows changed from offset to static. +#[test] +fn re_authoring_keeps_the_hand_offset_and_removal_ends_it_alike_on_both_hosts() { + for rate in RATES { + let model = automated_vca_model(rate, |model| { + model.automation[0].offset.left_db = 3.0; + }); + let label = format!("{rate} Hz"); + let ride = model.automation[0].id.clone(); + let static_db = [ + model.tracks[1].fader.left_db, + model.tracks[1].fader.right_db, + ]; + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + same_blocks(&mut browser, &mut c_abi, 0..3, &label); + let (epoch, rows) = t2_fader_rows(|payload, expected| browser.command(payload, expected)); + assert_eq!( + rows, + vec![ + ( + Some(protocol::ParameterChannel::Left), + 3.0_f32.to_bits(), + true + ), + ( + Some(protocol::ParameterChannel::Right), + 0.0_f32.to_bits(), + true + ), + ], + "{label}: the stored offset is the row's value" + ); + + let segments = vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: u64::from(QUANTUM) * BLOCKS, + start_value: -9.0, + end_value: -1.0, + unit: session::ParameterUnit::Db, + }]; + let reauthor = [SessionEdit::SetAutomationSegments { + automation_id: ride.clone(), + segments, + }]; + let (b, b_reply) = browser.transaction_reply(&reauthor); + let (c, c_reply) = c_abi.transaction(&reauthor); + assert_eq!(b, c, "{label}: one verdict"); + assert_eq!( + without_request_id(b_reply), + without_request_id(c_reply), + "{label}: one reply" + ); + assert_eq!( + b, + Verdict::Applied { + path: EditPath::Rebuild, + revision: model.revision + 1 + }, + "{label}: a curve edit rebuilds" + ); + same_blocks( + &mut browser, + &mut c_abi, + 3..8, + &format!("{label}: across the swap"), + ); + let kept = t2_fader_rows(|payload, expected| browser.command(payload, expected)); + assert_eq!( + kept, + (epoch, rows.clone()), + "{label}: the curve edit keeps the offset" + ); + let document = snapshot_bytes(|payload, expected| browser.command(payload, expected)); + let document = session::parse_session_json(&String::from_utf8(document).expect("utf-8")) + .expect("the committed document"); + assert_eq!( + document.automation[0].offset.left_db.to_bits(), + 3.0_f32.to_bits(), + "{label}: the document keeps the offset" + ); + + let remove = [SessionEdit::RemoveAutomation { + automation_id: ride, + }]; + let (b, b_reply) = browser.transaction_reply(&remove); + let (c, c_reply) = c_abi.transaction(&remove); + assert_eq!(b, c, "{label}: one verdict"); + assert_eq!( + without_request_id(b_reply), + without_request_id(c_reply), + "{label}: one reply" + ); + assert_eq!( + b, + Verdict::Applied { + path: EditPath::Rebuild, + revision: model.revision + 2 + }, + "{label}: a removal rebuilds" + ); + same_blocks( + &mut browser, + &mut c_abi, + 8..14, + &format!("{label}: after the removal"), + ); + let (fresh, removed) = + t2_fader_rows(|payload, expected| browser.command(payload, expected)); + assert_eq!( + removed, + vec![ + ( + Some(protocol::ParameterChannel::Left), + static_db[0].to_bits(), + false + ), + ( + Some(protocol::ParameterChannel::Right), + static_db[1].to_bits(), + false + ), + ], + "{label}: the lanes read their static fader again" + ); + assert!(fresh > epoch, "{label}: the structure epoch advanced"); + let stale = Some(vec!["control.list.stale".to_owned()]); + assert_eq!( + list_refusal( + |payload, expected| browser.command(payload, expected), + epoch + ), + stale, + "{label}: the browser refuses the old epoch" + ); + assert_eq!( + list_refusal(|payload, expected| c_abi.send(payload, expected), epoch), + stale, + "{label}: the C ABI refuses the old epoch" + ); + } +} diff --git a/scripts/check-protocol-wasm-parity.sh b/scripts/check-protocol-wasm-parity.sh index b60830bc3..7ff5de897 100755 --- a/scripts/check-protocol-wasm-parity.sh +++ b/scripts/check-protocol-wasm-parity.sh @@ -169,8 +169,8 @@ PY ) local -a files=("$PIN_FILE" "$PIN_FILE" "$GUEST_FILE") local -a searches=( - 'COMPLETE_SCHEMA_HASH: u64 = 0x5eb9_ad2e_d137_f3be;' - 'COMPLETE_SCHEMA_HASH: u64 = 0x5eb9_ad2e_d137_f3be;' + 'COMPLETE_SCHEMA_HASH: u64 = 0x5cab_2cf3_b4e4_767f;' + 'COMPLETE_SCHEMA_HASH: u64 = 0x5cab_2cf3_b4e4_767f;' 'assert_eq!(corpus.len(), 46);' ) local -a replacements=( diff --git a/sdk/src/core/control-list.ts b/sdk/src/core/control-list.ts index 872102466..1e14700f1 100644 --- a/sdk/src/core/control-list.ts +++ b/sdk/src/core/control-list.ts @@ -276,8 +276,14 @@ export interface ControlListEntry { readonly family: ControlFamilyName; /** `false` for a row the owner's present shape does not use. */ readonly active: boolean; - /** The committed value when its page was read. */ + /** + * The committed value when its page was read. On an automated lane (`automated`) of a strip's + * `fader_db` row it is the lane's stored hand-move offset in dB: a `SET_CONTROL` of the row + * writes the offset, and the stored curve renders under it (#1650). + */ readonly value: number; + /** Whether a rendered stored automation drives the row's lane (#1650 D4). */ + readonly automated: boolean; } /** The control list of the running session, in the core's order. */ @@ -587,6 +593,7 @@ function decodeEntry(row: RawField): ControlHeader | ControlListEntry { family: named(FAMILIES, requiredU8(fields, 32, "a family"), "the family"), active: boolOf(fields, 33, "active"), value: requiredF32(fields, 34, "a value"), + automated: boolOf(fields, 35, "automated"), }); } From 59ca8e3b493e3002612d17586ce8f4ed62daab14 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 20:59:25 +0000 Subject: [PATCH 21/35] Fold the U2 review of #1647: gates, the held-hold release, R17-8 and R17-9 R17-10 (render fix): a live write that frees a held lane releases the cell's hold in the same block (bank, fused bank, scalar oracle). R17-8: a seek Set at E sets the lane exactly and discharges the move. R17-9: the move at E is a SeekMode::Ramp landing aimed at target_at(E + J). New gates for constraint 7, S composition in render, seeks inside a node block and after E, the gained ride on a carried static fader (#1648b), gate 2 one block late, oracle latency and the fused path, ends-muted and left-only cases. The never-read refusal and stop counters are deleted. Lane slot 224 B (one-lane charge 2,044 B). Realtime policy 160; pcm_digest unchanged. Co-Authored-By: Claude Opus 5.5 --- .../1646-prepare-stored-fader-automation.md | 6 + ...7-render-moving-stored-fader-automation.md | 134 ++++ crates/builtins-compiler/src/lib.rs | 274 +++---- crates/capi/src/runtime/tests.rs | 9 +- .../tests/stored_fader_automation.rs | 750 ++++++++++++++++-- docs/SESSION_SCHEMA_V1.md | 6 +- 6 files changed, 970 insertions(+), 209 deletions(-) diff --git a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md index d4de070ac..3fc67368e 100644 --- a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md @@ -529,6 +529,12 @@ fixture). Each further automated channel in the same bank adds 120 plus its cell A session without fader automation carries the empty slot array: `size_of::()` grows by 16 bytes per fader bank, a fixed structural change inside every ceiling (no re-pin). +*Re-measured at the #1647 U2 fold (batch 17), same host:* #1647a-c, #1648b and #1649 grew the slot +(240 bytes at `275ff76e8`) and the U2 fold removed #1647b's two unread render counters: lane slot +224, program overhead 64, offsets cell 120. The one left-only lane's charge is +`68 + 64 + 8 × 224 + 120 = 2,044` bytes (`builtin_bank_bytes` 8,472 -> 10,516 on the bank +fixture); each further automated channel in the same bank adds 120 plus its cells' program bytes. + **Departures.** 1. **The program is built in the builtins preparation, not in host-core** (departure 6 said diff --git a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md index cc5a5faa1..c2d71d3fe 100644 --- a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md @@ -698,3 +698,137 @@ carried held ramp with `hold_until` at adoption (`CellState::reposition` drops i `fresh` on a carried lane. The successor's first block reads the stage state itself, so `E` needs no carry; `due_at_entry` must carry with `owed` (a ramp ending on the predecessor's last block). A release cell takes the same obligation and the same move. + +## Amendment (U2 fold, batch 17, from root's rulings R17-8, R17-9 and R17-10) + +These amend the batch 13b amendment and gate 8 above; the filed text stays as it was filed. + +- **The move (R17-9).** Where the batch 13b amendment says "the lane moves to the automation value + the cell then holds" and gate 8 (a)/(b) say "the gain composed from the cell's `target()`", read: + at `E` the cell lands as a seek in `SeekMode::Ramp` does, one `Jump` over the block's fader jump + length `J` aimed at the curve at the ramp's completion `E + J - 1` (never past the next + discontinuity, which jumps of its own), composed as D1.4, so the lane arrives on the curve at + `E + J - 1`. On a curve flat after `E` this is the cell's target, so (a)-(d) keep their bits. + The landing commits that target and holds the grid for its own ramp (constraint 3). +- **Constraint 7 (R17-8).** "The move at `E` replaces any gain operation the cell gives at `E`" + holds for a `Jump` and a grid event. A seek `Set` that lands exactly at `E` is pushed instead: + the gain is free from `E`, so it sets the lane there exactly (D3), and it discharges the + obligation. Constraint 5 ("a seek `Set` inside a held ramp lands by the move") is for a seek + before `E`. +- **Constraint 4 (R17-10).** A live write that frees the gain at a block entry discharges an owed + obligation and, in the same block, releases the cell's hold (D1.2's report, or the cell's own + ramp end), so the grid moves the lane from that block's first sample. +- **Gate 8 (e).** "Samples `[E, E + J - 1]` equal the stage driven directly with one `RetargetGain` + at `E` over `J`" reads: with one `RetargetGain` at `E` over `J` to the curve at `E + J - 1`, and + the one grid retarget #1637 commits in that ramp's last 64 samples (the grid point at + `⌊(E + J - 1)/64⌋·64`, over 64 samples, to the curve at its completion); every other grid point + in the ramp is held (#1647c departure 1, confirmed by the U2 review). +- **Gate 8's test value.** "Red if D1.2 refreshes `hold_until` inside the hold" is withdrawn: under + R17-9 the landing at `E` rewrites the whole cell state, and before `E` the stage only remembers, + so neither a refresh nor a missing D1.2 report reaches the output (U2 fold, finding MAJOR-1). + +## U2 fold (batch 17) + +The U2 review of #1647 (at `5e695ecb6`) and root's rulings R17-8, R17-9 and R17-10, folded on +`codex/controls-batch-17k` over `275ff76e8`. Uncommitted for the coordinator. All tests in +`crates/host-core/tests/stored_fader_automation.rs` unless named otherwise. Each "red" was checked +once against the mutation the review found green (or the stated revert), then the code restored. + +- **MAJOR-1 (constraint 1, D1.2): not gateable after R17-9; no test added.** A case with a `Jump` + at 950 inside the unmute ramp, a fade after it and a quantum-64 block entry on grid point 960 + (4 quanta and the oracle) stayed green under both mutations (D1.2 removed; `hold_until` + refreshed at every held block entry). Reason: before `E` the stage only remembers every gain + operation, a refresh only moves the cell's state, and the R17-9 landing at `E` rewrites all of + that state (cursor, target, held flag, ramp end). Without a move nothing was remembered, so the + hold changes nothing; after a live write R17-10 releases the hold. The test was deleted (it + defended nothing); D1.2 is kept per R17-10. Gate 8's test-value clause is withdrawn above. +- **MAJOR-2 (constraint 7).** `the_move_at_the_unmute_ramps_end_replaces_the_cells_event_there`: a + step exactly at `E` (to `-15` dB at 1,000 after a remembered fade), and a grid point at `E` + (an unmute of 1,024 on a fade); 4 quanta and the oracle. Red (the test process aborts on the + sink refusal's `debug_assert` inside render) with the cell's event pushed beside the move. +- **MAJOR-3 (D1.4, `S`).** `a_vca_offset_composes_with_every_fader_event`: gate 1's fade on a + member of a VCA at `-6` dB, every completion sample `checked_fader_gain(vca_compose_db(curve, + S))` and every sample the stage driven directly with the composed retargets. Red with the event + gain composed without `S`. A live `S` change stays covered by + `the_scalar_oracle_renders_the_banks_fader_events` (#1649's writes), not added here. +- **MAJOR-4 (seeks inside a node block).** + `a_session_seek_sets_a_latent_fader_lane_exactly_where_it_reaches_the_node` (gate 4 behind a + true-peak limiter, quanta 128 and 100: one `Set` per channel at `3,200 + a(n)`, the unseeked + bits before it, the static plan's bits from it); red with the seek step moved to the block's + offset 0. `a_seek_step_after_the_unmute_ramps_end_in_its_block_still_lands` (gate 8 with a + latent insert, quanta 100 and 37 with the oracle: the seek's `Set` after `E` in `E`'s block); + red with `hold_end` dropping a step after `E`. +- **MAJOR-5 / R17-10 (render fix).** `HeldRampObligation::block` returns whether a free block + entry discharged an owed obligation; `FaderEventDriver::new` then reports a hold that ended + before the block (`hold_until(node(0) - 1)`), which releases the cell's hold. One driver, so + the bank, the fused bank and the scalar oracle all take it. + `a_live_write_that_frees_a_held_lane_releases_its_hold` (a 4,000-sample unmute ended by a + window-0 unmute at 3,200 on a `-30 -> 0` dB fade: every completion sample from 3,263 is the + curve's gain; quanta 128, 100, 64 and the oracle). Red on the code before the fix. +- **#1648b MAJOR (required gate).** Already fixed by #1648b's comparison with the stage's gain. + `a_ride_gained_on_a_carried_static_fader_jumps_from_the_stages_gain` (predecessor static at + `-20` dB; the carried successor adds a ride flat at `0` dB; Simd8 and Simd4: the swap block + ramps, and every block after the jump equals the successor rendered fresh). Red with the + adoption compared with the cell's seed. +- **R17-8 (render fix).** `apply` pushes a seek `Set` that lands exactly at `E` and discharges the + obligation; the cut then makes no move. `a_seek_set_exactly_at_the_unmute_ramps_end_sets_the_lane` + (behind a latent insert, the seek and the unmute's end both at `3,200 + a(n)`, mid-block at + quanta 128, 100 and 64, and the oracle: one `Set` per channel at `E` in the probe, the static + plan's bits from `E`). Red without the rule. +- **R17-9 (render change).** `FaderCellCursor::cut` lands the cell at `E` through `CellEvents` on + a one-sample span whose seek step is at its offset 0, in `SeekMode::Ramp`: #1637's own landing + (`target_at(E, J)`, the curve at `E + J - 1`, capped before the next discontinuity; commit and + ramp end). Allocation-free and bounded: one event, one binary search of the discontinuities and + one curve read. Its reach is the fader jump `J`, the reach a `Jump` already has; the fader has + no filter order rule, so `ORDER_REACH` (the input filters' 128 samples) is not touched. + Moving curves: `grid_events_after_the_unmute_ramps_end_do_not_cut_the_move` (updated: the + target is now `curve(E + J - 1)`; red on the pre-R17-9 move, which aimed at `curve(1,023)`), and + the moving cases of the MAJOR-2, MINOR-11 and MAJOR-4 tests. Flat curves keep their bits: gate + 8 (a)-(d) pass unchanged. +- **MINOR-7 (counters): deleted.** `LaneFaderAutomation::{refused_events, operations_stopped}`, + the driver's per-lane `refused` array (and its lane >= 8 drop) and `settle_fader_events` are + gone. Each counted condition is unreachable and fails a `debug_assert` first (in a debug test + build the process aborts before a count could be read), so no test can drive a count above 0; + nothing read them. A refused event keeps the stage's target and fails its `debug_assert`; a + stage that stops pulling fails its own. This withdraws D1's "adds one to a render counter", + departure 6 of the batch 17 amendment, constraint 6 of the batch 13b amendment and #1647b's + departure 1. +- **MINOR-9.** `a_written_jump_length_reaches_the_next_jump_not_the_one_in_flight` also writes at + 2,944, the entry of the block holding the second step. Red with the length read one block + late. +- **MINOR-10.** `the_scalar_oracle_renders_the_banks_fader_events`: track 0 sits behind a + true-peak limiter and every ride ends by 6,000, and the bank prepared between render calls + must both fuse (the fused path with operations) and fall back. Red with the oracle's + `set_input_arrival` ignored. +- **MINOR-11.** `a_ramp_that_ends_muted_keeps_the_obligation_and_a_hold_with_nothing_remembered_does_not_move` + (constraint 4: a 300-sample mute at 0, the later unmute's end lands; departure 3: a hold with + nothing remembered renders the free lane from `E`); red with a muted entry discharging, and red + with a move at every hold end. `a_left_only_unmute_moves_the_left_lane_alone` (dual mono); red + with the left channel's hold read for both. +- **MINOR-12.** `docs/SESSION_SCHEMA_V1.md` names the held-ramp exception to "set exactly where a + seek reaches the fader". Gate 8 (c) and (d) no longer claim "red if a grid event cuts the move" + (their curves are flat after `E`; (e) gates it). The C ABI gate 4 doc says the raised-floor leg + lands the seek through the successor's step (#1647b departure 3). +- **NITs.** `block` cuts at a due entry only when a move is owed (`due && owed`), so an entry + with nothing owed may fuse. The probe records the move (a `Jump` at `E`). A const assert holds + every `BankWidth::ALL` width within `MAXIMUM_BANK_LANES` (and so `MAXIMUM_FADER_CELLS`). #1646's + "Measured" paragraph is re-measured in a note under it. Gate 8 (e)'s text is amended above. + +**Measured (x86-64, AVX2).** `lane_slot_bytes` 240 -> 224 (the two counters), by +`fader_automation_sizes()`; the one-lane charge is `68 + 64 + 8 × 224 + 120 = 2,044` bytes. No +fixture moved. + +**Departures.** MAJOR-1 has no gate (above). MINOR-7 deletes the counters rather than reading +them. Out-of-path edits: `.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md` (the +re-measured note). No edit under `crates/engine/src/realtime/`, `crates/automation` or +`crates/builtins`. + +**Checks (x86-64 AVX2).** `cargo test --locked -p automation -p graph -p builtins -p +builtins-compiler --features builtins-compiler/test-support,graph/test-support`: 388 pass. +`-p host-core --all-features`: 555 pass (`stored_fader_automation` 26; 23 without features). +`-p capi`: 206 pass. `-p host-web --features host-web/test-support --lib --test +control_parity`: 173 pass. `-p control-plane --all-features`: 24 pass. `realtime-policy`: ok, 160 +regions in 33 files (unchanged). fmt; clippy `-D warnings` (builtins-compiler, host-core, capi; +all targets, all features); no-feature `cargo check --all-targets` on the same: clean. +`./target/release/audit capi`: `pcm_digest` `bffc21e857b5e134` (unchanged), 0 violations, 0 +allocations. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index a67789b9a..0749aaf6e 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -790,16 +790,17 @@ enum Adoption { Reposition, } -/// One automated channel's obligation to move to its cell's target where a held ramp ends with -/// the gain free (#1647 D1.6, batch 13b amendment): while the stage holds the lane's gain (muted, +/// One automated channel's obligation to move to its curve where a held ramp ends with the gain +/// free (#1647 D1.6, batch 13b amendment, R17-9): while the stage holds the lane's gain (muted, /// or in a mute or unmute ramp, #1638 D2) a gain event is only remembered, and on a curve flat /// afterwards the cell emits nothing more, so without the move the lane would stay at the gain /// the unmute ramp aimed for. #[derive(Clone, Copy, Debug, Default)] struct HeldRampObligation { /// A gain event the stage only remembered is outstanding. Kept through a ramp that ends - /// muted (constraint 4); cleared by the move, or when a live write freed the gain at a block - /// entry (it discharges the obligation there). + /// muted (constraint 4); cleared by the move, by a seek `Set` exactly at the hold's end + /// (R17-8), or when a live write freed the gain at a block entry (it discharges the + /// obligation there and the cell's hold is released, R17-10). owed: bool, /// The held ramp ends on the last sample of the block just rendered: the move is due at the /// next block's first sample, which is the first sample the stage reports the gain free. @@ -827,14 +828,6 @@ struct LaneFaderAutomation { /// The lane has rendered no block yet (#1647 D2): its first block takes a `Set` when the /// curve at that block's node time differs from the value it was seeded with. fresh: bool, - /// Events render refused (#1647 D1, unreachable): a gain the conversion refuses, a sink - /// refusal, a cell the program does not hold, a span the cell refuses or a cell past the - /// driver's capacity. Each also fails a `debug_assert`. Read off render. - refused_events: u64, - /// Blocks in which the stage stopped pulling this lane's owner's operations - /// (`BuiltinProcessReport::fader_operations_stopped`, #1638): a bank's stop is counted on each - /// of its automated lanes. Read off render. - operations_stopped: u64, } impl LaneFaderAutomation { @@ -864,8 +857,6 @@ impl LaneFaderAutomation { span: None, fader_jump: None, fresh: true, - refused_events: 0, - operations_stopped: 0, } } @@ -954,7 +945,6 @@ impl LaneFaderAutomation { program, channels, span, - refused_events, .. } = self; for (channel, cell) in channels.iter_mut().enumerate() { @@ -974,10 +964,8 @@ impl LaneFaderAutomation { continue; }; let record = FaderOffsetsRecord::from_words(words); - if !cell.offsets_jump(program, *span, channel, record, stage) { - debug_assert!(false, "an offsets jump is refused at render"); - *refused_events = refused_events.saturating_add(1); - } + let jumped = cell.offsets_jump(program, *span, channel, record, stage); + debug_assert!(jumped, "an offsets jump is refused at render"); } } // REALTIME_POLICY_END @@ -1259,19 +1247,31 @@ impl FaderCellCursor<'_> { .or_else(|| self.end.map(|end| end.at)) } - /// End the first piece at the cut and start the second, after the move at `E` (if any) has - /// reported its ramp to the cell. Returns the cell's target, for the move. - fn cut(&mut self, end: HoldEnd, moving: bool) -> Option { + /// End the first piece at the cut and start the second. With `moving`, the cell first lands + /// at `E` as a seek in [`automation::SeekMode::Ramp`] does (R17-9): one `Jump` over the + /// block's jump length `J`, aimed at the curve at the ramp's completion `E + J - 1` (never + /// past the next discontinuity), which commits that target and holds the grid for the move's + /// own ramp (constraint 3), as after the cell's own `Jump`. Returns that landing. Bounded: one + /// event of a one-sample span, a binary search of the discontinuities and one curve read. + fn cut(&mut self, end: HoldEnd, moving: bool) -> Option { self.end = None; - let state = self.events.take()?.into_state(); - let target = state.target(); + let mut state = self.events.take()?.into_state(); + let mut landing = None; if moving { - // Constraint 3: the move holds the grid for its own ramp, as after the cell's Jump. - state.hold_until(automation::completion_of( - automation::Completion::End, - end.node, - self.jump, - )); + match automation::NodeSpan::new(end.node, Some((0, end.node)), 1) { + Ok(span) => { + let mut events = automation::CellEvents::new( + self.program, + state, + span, + automation::SeekMode::Ramp, + self.jump, + ); + landing = events.next(); + state = events.into_state(); + } + Err(_) => debug_assert!(false, "a one-sample span with its step at 0 is valid"), + } } if let Some(rest) = end.rest { self.base = end.at.saturating_add(1); @@ -1284,7 +1284,7 @@ impl FaderCellCursor<'_> { )); self.pull(); } - target + landing } } @@ -1301,14 +1301,16 @@ impl FaderCellCursor<'_> { /// /// D1.6 (batch 13b amendment): a gain event pushed while the stage holds the channel's gain is /// only remembered, so the channel owes a move. At the first sample `E` at which the stage -/// reports the gain free, the driver moves the channel to its cell's target over the block's jump -/// length, in place of any event the cell gives at `E`, and reports that ramp to the cell; the -/// channel's span is cut there, so no event past `E` is pulled before the move. Fixed size, on the -/// render stack; allocation-, lock- and syscall-free. +/// reports the gain free, the driver moves the channel with a ramped landing of its cell at `E` +/// (R17-9: aimed at the curve at `E + J - 1`, over the block's jump length `J`), in place of any +/// event the cell gives at `E`; a seek `Set` exactly at `E` is pushed instead and discharges the +/// obligation (R17-8). The channel's span is cut there, so no event past `E` is pulled before the +/// move. A live write that frees the gain at a block entry discharges the obligation and releases +/// the cell's hold there (R17-10). An event render refuses (unreachable: the curve is in the fader +/// domain, `vca_compose_db` clamps and the lane is a member) keeps the stage's target and fails a +/// `debug_assert`. Fixed size, on the render stack; allocation-, lock- and syscall-free. struct FaderEventDriver<'a> { cursors: [Option>; MAXIMUM_FADER_CELLS], - /// Refused events per owner lane, returned by [`Self::finish`]. - refused: [u64; MAXIMUM_BANK_LANES], #[cfg(any(test, feature = "test-support"))] first_sample: u64, } @@ -1333,7 +1335,6 @@ impl<'a> FaderEventDriver<'a> { let _ = first_sample; let mut driver = Self { cursors: core::array::from_fn(|_| None), - refused: [0; MAXIMUM_BANK_LANES], #[cfg(any(test, feature = "test-support"))] first_sample, }; @@ -1375,14 +1376,10 @@ impl<'a> FaderEventDriver<'a> { else { continue; }; - let refused = |driver: &mut Self| { - debug_assert!(false, "an automated fader cell is refused at render"); - if let Some(count) = driver.refused.get_mut(lane) { - *count = count.saturating_add(1); - } - }; + let refused = + || debug_assert!(false, "an automated fader cell is refused at render"); let Some(cell_program) = program.cell(*cell as usize) else { - refused(&mut driver); + refused(); continue; }; let mut step = span @@ -1411,7 +1408,13 @@ impl<'a> FaderEventDriver<'a> { step = Some((0, span.start)); mode = automation::SeekMode::Ramp; } - let (free_from, at) = obligation.block(hold_now, frames); + let (free_from, at, discharged) = obligation.block(hold_now, frames); + if discharged && let Some(first) = span_time(span.start, step, 0) { + // R17-10: the live write that freed the gain discharged the obligation, so + // the cell's hold of the ramp it ended is released here: its grid moves the + // lane from this block's first sample on. + state.hold_until(first.saturating_sub(1)); + } let end = at.map(|at| hold_end(span.start, step, at, frames)); let first = match end { Some(Some(end)) => automation::NodeSpan::new( @@ -1426,7 +1429,7 @@ impl<'a> FaderEventDriver<'a> { driver.cursors.get_mut(used), matches!(end, Some(None)), ) else { - refused(&mut driver); + refused(); continue; }; let mut cursor = FaderCellCursor { @@ -1482,10 +1485,10 @@ impl<'a> FaderEventDriver<'a> { }) } - /// Consume every event the stage did not pull (none, unless a stage stopped pulling: the - /// cell's state must still advance through its block) and return the refused events per - /// owner lane. A cut the stage did not reach starts its second piece with no move. - fn finish(mut self) -> [u64; MAXIMUM_BANK_LANES] { + /// Consume every event the stage did not pull (none, unless a stage stopped pulling, which + /// fails its own `debug_assert`: the cell's state must still advance through its block). A + /// cut the stage did not reach starts its second piece with no move. + fn finish(mut self) { for cursor in self.cursors.iter_mut().flatten() { while cursor.next.is_some() { cursor.pull(); @@ -1497,17 +1500,18 @@ impl<'a> FaderEventDriver<'a> { } } } - self.refused } } impl HeldRampObligation { /// The block's hold of one channel from its stage state `hold` after the live drain: the - /// first offset at which the gain is free, and `E` when the hold ends with the gain free in - /// this block (a ramp that ends exactly at the block's end makes `E` the next block's first - /// sample). A channel free at a block entry with no move due had its hold cleared by a live - /// write there, which discharges the obligation (constraint 4). - fn block(&mut self, hold: Option, frames: u32) -> (u32, Option) { + /// first offset at which the gain is free, `E` when the hold ends with the gain free in this + /// block (a ramp that ends exactly at the block's end makes `E` the next block's first sample, + /// when a move is owed), and whether a live write discharged the obligation here. A channel + /// free at a block entry with no move due had its hold cleared by a live write there, which + /// discharges an owed obligation (constraint 4); the caller then releases the cell's hold + /// (R17-10). + fn block(&mut self, hold: Option, frames: u32) -> (u32, Option, bool) { let due = core::mem::take(&mut self.due_at_entry); match hold { Some(ChannelHold { @@ -1517,19 +1521,16 @@ impl HeldRampObligation { .. }) if remaining > 0 => { if remaining < frames { - (remaining, Some(remaining)) + (remaining, Some(remaining), false) } else { self.due_at_entry = remaining == frames; - (frames, None) + (frames, None, false) } } - Some(ChannelHold { held: true, .. }) => (frames, None), - Some(_) if due => (0, Some(0)), - Some(_) => { - self.owed = false; - (0, None) - } - None => (0, None), + Some(ChannelHold { held: true, .. }) => (frames, None, false), + Some(_) if due && self.owed => (0, Some(0), false), + Some(_) => (0, None, core::mem::take(&mut self.owed)), + None => (0, None, false), } } } @@ -1621,77 +1622,57 @@ impl builtins::FaderOperationSource for FaderEventDriver<'_> { fn apply(&mut self, offset: u32, sink: &mut builtins::FaderOperationSink) { #[cfg(any(test, feature = "test-support"))] let first_sample = self.first_sample; - let Self { - cursors, refused, .. - } = self; - for cursor in cursors.iter_mut().flatten() { + for cursor in self.cursors.iter_mut().flatten() { let event = cursor.next.filter(|event| event.offset == offset); let end = cursor.end.filter(|end| end.at == offset); if event.is_none() && end.is_none() { continue; } - let selector = if cursor.channel == 0 { - BuiltinLaneSelector::Left - } else { - BuiltinLaneSelector::Right - }; - let lane = cursor.lane; - let mut refuse = || { - debug_assert!(false, "a stored fader event is refused at render"); - if let Some(count) = refused.get_mut(lane) { - *count = count.saturating_add(1); - } + let selector = channel_selector(cursor.channel); + // R17-8: a seek `Set` that lands exactly at `E` sets the lane there (the gain is + // free from `E`) and discharges the obligation (D3 wins over constraint 7). + let set_at_end = end.is_some() + && event.is_some_and(|event| event.kind == automation::EventKind::Set); + // Constraint 7: otherwise the move at `E` replaces any gain operation the cell gives + // at `E`. + let moving = end.is_some() && cursor.obligation.owed && !set_at_end; + let mut push = |cursor: &FaderCellCursor<'_>, event: automation::Event| { + let pushed = Self::operation(event, cursor.offsets_sum) + .is_some_and(|operation| sink.push(cursor.lane, selector, operation).is_ok()); + debug_assert!(pushed, "a stored fader event is refused at render"); + #[cfg(any(test, feature = "test-support"))] + test_only_record_fader_event(TestOnlyFaderEvent { + lane: cursor.lane, + channel: cursor.channel, + sample: first_sample.saturating_add(u64::from(offset)), + kind: event.kind, + held: event.held, + value: event.value, + ramp: event.ramp, + }); }; - // Constraint 7: the move at `E` replaces any gain operation the cell gives at `E`. - let moving = end.is_some() && cursor.obligation.owed; if let Some(event) = event { if !moving { - let pushed = - Self::operation(event, cursor.offsets_sum).is_some_and(|operation| { - sink.push(cursor.lane, selector, operation).is_ok() - }); - if !pushed { - refuse(); - } + push(cursor, event); // The stage only remembers a gain while it holds the lane. if offset < cursor.free_from { cursor.obligation.owed = true; } - #[cfg(any(test, feature = "test-support"))] - test_only_record_fader_event(TestOnlyFaderEvent { - lane: cursor.lane, - channel: cursor.channel, - sample: first_sample.saturating_add(u64::from(offset)), - kind: event.kind, - held: event.held, - value: event.value, - ramp: event.ramp, - }); } cursor.pull(); } let Some(end) = end else { continue; }; - let target = cursor.cut(end, moving); + if set_at_end { + cursor.obligation.owed = false; + } + let landing = cursor.cut(end, moving); if moving { cursor.obligation.owed = false; - let jump = cursor.jump; - let pushed = target - .and_then(|target| Self::gain(target, cursor.offsets_sum)) - .is_some_and(|gain| { - sink.push( - cursor.lane, - selector, - builtins::FaderOperation::RetargetGain { - gain, - samples: jump, - }, - ) - .is_ok() - }); - if !pushed { - refuse(); + match landing { + Some(landing) => push(cursor, landing), + None => debug_assert!(false, "a moving cut lands its cell"), } } } @@ -2572,6 +2553,16 @@ struct BuiltinBankProcessor { /// Widest bank any backend selects (`BankWidth::Eight`), as a plain array bound. const MAXIMUM_BANK_LANES: usize = 8; +// Every bank width this build has fits the arrays bound by `MAXIMUM_BANK_LANES` (the live +// witnesses, the lane masks and `MAXIMUM_FADER_CELLS`' cursors): a wider width fails the build. +const _: () = { + let mut index = 0; + while index < BankWidth::ALL.len() { + assert!(BankWidth::ALL[index].lanes() as usize <= MAXIMUM_BANK_LANES); + index += 1; + } +}; + /// The lane mask of a drain that visits every lane of a bank: the block drain's. const EVERY_LANE: u8 = u8::MAX; @@ -3196,9 +3187,9 @@ impl GraphPreparedBuiltinBankProcessor for FaderBankProcessor { first_sample, bank_hold(bank), ); - let report = bank.process_with(left, right, frames, &mut driver); - let refused = driver.finish(); - settle_fader_events(automation, refused, report); + // A stop is a broken source contract: the stage fails its own `debug_assert`. + let _ = bank.process_with(left, right, frames, &mut driver); + driver.finish(); } self.process_calls = self.process_calls.saturating_add(1); self.frames_processed = self.frames_processed.saturating_add(u64::from(frames)); @@ -3249,22 +3240,6 @@ fn bank_hold(bank: &BuiltinFaderBank) -> impl Fn(usize, usize) -> Option], - refused: [u64; MAXIMUM_BANK_LANES], - report: builtins::BuiltinProcessReport, -) { - for (slot, refused) in automation.iter_mut().zip(refused) { - if let Some(slot) = slot { - slot.refused_events = slot.refused_events.saturating_add(refused); - slot.operations_stopped = slot - .operations_stopped - .saturating_add(report.fader_operations_stopped); - } - } -} - /// #1649 D3: each automated lane with a live control lane reads its offsets cells under the /// block's `snapshot` and jumps ([`LaneFaderAutomation::read_offsets`]). A lane with no control /// lane has no writer, so its cells stay as prepared. @@ -4046,17 +4021,17 @@ impl GraphPreparedBuiltinBankProcessor for FaderMatrixBankProcessor { counters[2] = counters[2].saturating_add(1); value.set(counters); }); - let report = match driver.as_mut() { + match driver.as_mut() { None => { bank.process(left, right, frames); - builtins::BuiltinProcessReport::default() } - Some(driver) => bank.process_with(left, right, frames, driver), - }; + Some(driver) => { + let _ = bank.process_with(left, right, frames, driver); + } + } matrix.bank.process(left, right, frames); if let Some(driver) = driver.take() { - let refused = driver.finish(); - settle_fader_events(automation, refused, report); + driver.finish(); } } else { #[cfg(any(test, feature = "test-support"))] @@ -4068,12 +4043,7 @@ impl GraphPreparedBuiltinBankProcessor for FaderMatrixBankProcessor { value.set(counters); }); if let Some(driver) = driver.take() { - let refused = driver.finish(); - settle_fader_events( - automation, - refused, - builtins::BuiltinProcessReport::default(), - ); + driver.finish(); } } fader.process_calls = fader.process_calls.saturating_add(1); @@ -7404,12 +7374,8 @@ fn render_oracle_fader( }) }, ); - let report = fader.process_with(block, &mut driver); - let [refused, ..] = driver.finish(); - automation.refused_events = automation.refused_events.saturating_add(refused); - automation.operations_stopped = automation - .operations_stopped - .saturating_add(report.fader_operations_stopped); + let _ = fader.process_with(block, &mut driver); + driver.finish(); } } Ok(()) diff --git a/crates/capi/src/runtime/tests.rs b/crates/capi/src/runtime/tests.rs index 5dd1a70f5..34ab1b7f9 100644 --- a/crates/capi/src/runtime/tests.rs +++ b/crates/capi/src/runtime/tests.rs @@ -6591,11 +6591,12 @@ fn render_seek_ride_c( /// static fader at `-9` dB exactly from the render sample `r` the seek applied at (the fader has /// no latency before it), with no ramp, and differs before it. Twice: on a plan with no raised /// floor (the seek lands on the running plan as a `Set`), and on a plan whose floors #1285 raised, -/// where the plain seek publishes a discontinuity successor whose lanes start fresh (D2: the -/// restarted lane's first block takes a `Set`). +/// where the plain seek publishes a discontinuity successor whose lanes start fresh; its timeline +/// history carries the seek, so the seek's step in the successor's first block lands the `Set` +/// (#1647b departure 3), not the D2 restart `Set`. /// -/// Test value: red if a seek ramps the lane or reaches the fader at the wrong sample, or if a -/// restarted lane keeps its prepared value, on the C ABI. +/// Test value: red if a seek ramps the lane or reaches the fader at the wrong sample, on the +/// running plan or across the discontinuity successor, on the C ABI. #[test] fn a_session_seek_sets_an_automated_fader_exactly_on_the_c_abi() { let (limited, edits) = limited_session(); diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index c52d925af..dd5f30e53 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -876,12 +876,13 @@ fn a_fader_step_jumps_at_its_exact_sample() { } /// #1647 gate 2, the next jump and not the one in flight: steps at 1,000 and 3,000 with a fader -/// length of 480; a write of 960 to the plan's jump-length cell (#1639) at the block entry at -/// 1,152, while the first jump ramps. The first completes over 480 and the second ramps over 960, -/// bit for bit the stage driven directly with those two retargets. +/// length of 480; a write of 960 to the plan's jump-length cell (#1639) at a block entry: at +/// 1,152, while the first jump ramps, and at 2,944, the entry of the block that holds the second +/// step. In each the first completes over 480 and the second ramps over 960, bit for bit the stage +/// driven directly with those two retargets. /// /// Test value: red if a written length cuts the jump in flight, or if the length is read only at -/// preparation or a block late. +/// preparation or a block late (the write at 2,944 would reach the second step at 480). #[test] fn a_written_jump_length_reaches_the_next_jump_not_the_one_in_flight() { const FRAMES: u64 = 4_608; @@ -894,12 +895,6 @@ fn a_written_jump_length_reaches_the_next_jump_not_the_one_in_flight() { ParameterChannel::Both, steps(&[(1_000, -6.0), (3_000, -12.0)]), )]; - let mut rig = Rig::live(&model); - while rig.rendered < 1_152 { - rig.block(); - } - rig.write_fader_jump(&model, 960); - rig.until(FRAMES); let retarget = |db: f32, samples: u32| builtins::FaderOperation::RetargetGain { gain: gain(db), samples, @@ -909,11 +904,23 @@ fn a_written_jump_length_reaches_the_next_jump_not_the_one_in_flight() { &[(1_000, retarget(-6.0, 480)), (3_000, retarget(-12.0, 960))], FRAMES, ); - for plane in &rig.out { - assert_eq!(first_difference(&plane[..FRAMES as usize], &expected), None); - assert_eq!(plane[1_479].to_bits(), gain(-6.0).to_bits()); - assert_eq!(plane[3_959].to_bits(), gain(-12.0).to_bits()); - assert_ne!(plane[3_958].to_bits(), gain(-12.0).to_bits()); + for write_at in [1_152, 2_944] { + let mut rig = Rig::live(&model); + while rig.rendered < write_at { + rig.block(); + } + rig.write_fader_jump(&model, 960); + rig.until(FRAMES); + for plane in &rig.out { + assert_eq!( + first_difference(&plane[..FRAMES as usize], &expected), + None, + "written at {write_at}" + ); + assert_eq!(plane[1_479].to_bits(), gain(-6.0).to_bits()); + assert_eq!(plane[3_959].to_bits(), gain(-12.0).to_bits()); + assert_ne!(plane[3_958].to_bits(), gain(-12.0).to_bits()); + } } } @@ -1115,15 +1122,17 @@ fn rendering_fader_events_allocates_nothing() { assert_eq!(rig.allocations, (0, 0, 0), "1,000 blocks of fader events"); } -/// The session of the oracle parity test: a fade on both lanes of track 0, steps on the left lane -/// and a fade on the right lane of track 1, no automation on track 2; the default jump length. +/// The session of the oracle parity test: a fade on both lanes of track 0, which sits behind a +/// true-peak limiter (a latent insert, so its fader's node time is `a(n)` behind), steps on the +/// left lane and a fade on the right lane of track 1, no automation on track 2; the default jump +/// length. Every ride ends by 6,000 samples, so the later blocks have no fader event. #[cfg(feature = "test-support")] fn parity_model() -> SessionModel { - let mut model = plain(3, 128); - let mut right = fade(); - right[0].start_value = -12.0; + let mut model = with_limiter(plain(3, 128), 0); + let mut right = fade_from(-12.0, 3_000); + right[0].end_value = 0.0; model.automation = vec![ - fader_entry(&model, 0, ParameterChannel::Both, fade()), + fader_entry(&model, 0, ParameterChannel::Both, fade_from(-40.0, 2_000)), fader_entry( &model, 1, @@ -1208,11 +1217,16 @@ fn render_parity(mut rig: Rig, blocks: usize) -> [Vec; 2] { /// The live variants also write the offsets cells (#1649): a jump on a free lane and one under a /// held unmute ramp, read by the fader bank and by the oracle's live fader owner alike. /// -/// Test value: red if the oracle's fader owners ignore the program, take no node time or `a(n)`, -/// defer or fuse an automated fader past its events, or convert an event differently from the -/// bank: "a placement change moves no bit" would fail for automated sessions. Red too if the -/// oracle does not read the offsets cells, or jumps (or forgets a held lane's target) otherwise -/// than the bank. +/// The bank prepared between render calls fuses its fader-matrix pair with the event driver once +/// no fader event falls in a block (the fused path with operations, #1647 D1.5): its fused counter +/// advances, and the oracle, which never fuses an automated fader, renders the same bits. +/// +/// Test value: red if the oracle's fader owners ignore the program, take no node time or `a(n)` +/// (track 0's fader sits behind a latent insert), defer or fuse an automated fader past its +/// events, or convert an event differently from the bank: "a placement change moves no bit" would +/// fail for automated sessions. Red too if the oracle does not read the offsets cells, or jumps +/// (or forgets a held lane's target) otherwise than the bank, or if the bank's fused path with +/// operations renders otherwise than the timed path. #[cfg(feature = "test-support")] #[test] fn the_scalar_oracle_renders_the_banks_fader_events() { @@ -1247,6 +1261,15 @@ fn the_scalar_oracle_renders_the_banks_fader_events() { let mut unautomated = model.clone(); unautomated.automation.clear(); let without = render_parity(Rig::new(&unautomated), BLOCKS); + // Delivery between render calls forms the fused fader-matrix bank. + builtins_compiler::test_only_reset_fader_matrix_witness(); + let fused_bank = live_on(Backend::Simd4, true); + let witness = builtins_compiler::test_only_fader_matrix_witness(); + assert!( + witness.fused_calls > 0 && witness.fallback_calls > 0, + "the bank fuses its automated fader-matrix pair in the blocks with no event, and takes \ + the timed path in the others: {witness:?}" + ); for (label, bank, oracle) in [ ( "no live control", @@ -1260,7 +1283,7 @@ fn the_scalar_oracle_renders_the_banks_fader_events() { ), ( "between render calls", - live_on(Backend::Simd4, true), + fused_bank, live_on(Backend::Scalar, true), ), ] { @@ -1385,6 +1408,12 @@ fn unmuted_at(model: &SessionModel, quantum: u32, frames: u64, seek: Option /// [`unmuted_at`] on the test-only scalar oracle (R17-4) at the model's quantum. #[cfg(feature = "test-support")] fn unmuted_on_the_oracle(model: &SessionModel, frames: u64, seek: Option) -> [Vec; 2] { + unmuted(oracle_rig(model), frames, seek) +} + +/// A rig on the test-only scalar oracle (R17-4) with live controls and its plan's gate. +#[cfg(feature = "test-support")] +fn oracle_rig(model: &SessionModel) -> Rig { let document = canonical_session_json(model).expect("canonical"); let caps = rig_caps(model); let compiled = host_core::compile_host_session(&document, &caps) @@ -1400,7 +1429,7 @@ fn unmuted_on_the_oracle(model: &SessionModel, frames: u64, seek: Option) - let mut rig = Rig::from(prepared, model); rig.handles = Some(handles); rig.gate = gate; - unmuted(rig, frames, seek) + rig } /// Run `rig` (with live controls and its plan's gate) as [`unmuted_at`] describes. @@ -1513,25 +1542,36 @@ fn reference_from( } /// Assert that `out[E..E + J]` equals the stage driven directly from `out[E - 1]` with -/// `operations` (offsets from `E`). +/// `operations` (offsets from `E`), `E` = [`UNMUTE`]. fn assert_move( out: &[Vec; 2], jump: u32, operations: &[(u64, builtins::FaderOperation)], label: &str, ) { - let e = UNMUTE as usize; - let span = u64::from(jump.max(1)); for plane in out { - let expected = reference_from(plane[e - 1], operations, span); - assert_eq!( - first_difference(&plane[e..e + span as usize], &expected), - None, - "{label}: samples [E, E + J - 1]" - ); + assert_move_at(plane, UNMUTE as usize, jump, operations, label); } } +/// Assert that `plane[e..e + J]` equals the stage driven directly from `plane[e - 1]` with +/// `operations` (offsets from `e`). +fn assert_move_at( + plane: &[f32], + e: usize, + jump: u32, + operations: &[(u64, builtins::FaderOperation)], + label: &str, +) { + let span = u64::from(jump.max(1)); + let expected = reference_from(plane[e - 1], operations, span); + assert_eq!( + first_difference(&plane[e..e + span as usize], &expected), + None, + "{label}: samples [E, E + J - 1] from E = {e}" + ); +} + /// The move at `E`: one `RetargetGain` to `db` over `jump`. fn retarget(db: f32, jump: u32) -> (u64, builtins::FaderOperation) { ( @@ -1595,8 +1635,9 @@ fn a_remembered_jump_or_seek_set_moves_the_lane_at_the_unmute_ramps_end() { /// stage driven directly with one `RetargetGain` at `E` over `J`: no grid event cuts the move's /// ramp, and the bits are the same at every quantum. /// -/// Test value: red if the move does not take the cell's target, if it is built at a block entry, -/// or if a grid event cuts its ramp. +/// Test value: red if the move does not take the cell's target or if it is built at a block +/// entry. The curve is flat after the `Jump`, so no grid event can cut the move here; (e) gates +/// that. #[test] fn a_jump_ramping_past_the_unmute_ramps_end_is_moved_to_at_its_end() { const FRAMES: u64 = 2_048; @@ -1614,7 +1655,8 @@ fn a_jump_ramping_past_the_unmute_ramps_end_is_moved_to_at_its_end() { /// `E` the lane moves there and settles from `E + J - 1`. /// /// Test value: red if a remembered grid event is never moved to at `E` (the lane stays at the -/// pre-unmute gain on the flat curve), or if the move is cut by a grid event. +/// pre-unmute gain on the flat curve). The curve is flat after `E`, so no grid event can cut the +/// move here; (e) gates that. #[test] fn a_curve_flat_inside_the_unmute_ramps_last_grid_point_moves_the_lane() { const FRAMES: u64 = 2_048; @@ -1632,9 +1674,9 @@ fn a_curve_flat_inside_the_unmute_ramps_last_grid_point_moves_the_lane() { } /// The grid operations of the move's ramp at `E` (node time `node`) over `jump` on a curve with no -/// discontinuity after it: the move to the cell's target `target` dB, then the one grid point in -/// the ramp's last 64 samples, which #1637 commits as after the cell's own `Jump` (every earlier -/// grid point is held by the move's report, batch 13b constraint 3). +/// discontinuity after it: the move to `target` dB (R17-9: the curve at `node + jump - 1`), then +/// the one grid point in the ramp's last 64 samples, which #1637 commits as after the cell's own +/// `Jump` (every earlier grid point is held by the move's ramp, batch 13b constraint 3). fn move_operations( entry: &Automation, node: i64, @@ -1658,12 +1700,15 @@ fn move_operations( /// `Jump` whose ramp ends before `E` on a curve that moves after it (the grid point at timeline /// 1,024 lies before the next entry at quantum 100 and 37 only), and a seek `Set` (a session seek /// to timeline 2,000 drained with the unmute) on a moving curve. Samples `[E, E + J - 1]` equal the -/// stage driven directly with one `RetargetGain` at `E` over `J` and the one grid point #1637 -/// commits in that ramp's last 64 samples (departure 1 of #1647c): no other grid event cuts the -/// move's ramp, at any quantum. +/// stage driven directly with one `RetargetGain` at `E` over `J`, aimed at the curve at +/// `E + J - 1` (R17-9), and the one grid point #1637 commits in that ramp's last 64 samples +/// (departure 1 of #1647c, amended gate 8 (e)): no other grid event cuts the move's ramp, at any +/// quantum. /// /// Test value: red if the move does not hold the grid for its own ramp (the grid events after -/// `E` cut it into 64-sample retargets), or if it follows a block entry instead of `E`. +/// `E` cut it into 64-sample retargets), if it follows a block entry instead of `E`, or if it +/// aims at the cell's target (the curve one grid ramp ahead of the last grid point) instead of +/// the curve at its own completion. #[test] fn grid_events_after_the_unmute_ramps_end_do_not_cut_the_move() { const FRAMES: u64 = 2_048; @@ -1677,8 +1722,9 @@ fn grid_events_after_the_unmute_ramps_end_do_not_cut_the_move() { 10.0, ); let out = gate_eight(&model, &entry, FRAMES, None); - // The cell's target at `E`: the last grid point before it (960) committed the curve at 1,023. - let target = curve(&entry, 960 + 63); + // R17-9: the move lands at the curve at its completion, `E + J - 1`. + let target = curve(&entry, i64::from(UNMUTE + jump) - 1); + assert_ne!(target.to_bits(), curve(&entry, 960 + 63).to_bits()); assert_move( &out, jump, @@ -1692,7 +1738,7 @@ fn grid_events_after_the_unmute_ramps_end_do_not_cut_the_move() { ); let out = gate_eight(&model, &entry, FRAMES, Some(2_000)); let node = 2_000 + i64::from(UNMUTE); - let target = curve(&entry, (node - 1).div_euclid(64) * 64 + 63); + let target = curve(&entry, node + i64::from(jump) - 1); assert_move( &out, jump, @@ -1701,6 +1747,610 @@ fn grid_events_after_the_unmute_ramps_end_do_not_cut_the_move() { ); } +// The U2 fold of #1647 (batch 17): the gates the U2 review found missing, and rulings R17-8, +// R17-9 and R17-10. + +/// Write a live mute record on track 0's `lanes` (window `window`), applied at the next block +/// entry. +fn live_mute(rig: &mut Rig, lanes: builtins::BuiltinLaneSelector, muted: bool, window: u32) { + use builtins_compiler::TestOnlyWriteGate; + let handles = rig.handles.as_mut().expect("live controls"); + rig.gate + .as_mut() + .expect("the plan's gate") + .write_published(|stamp| { + handles.strip_controls[0].fader.write( + stamp, + builtins_compiler::TrackFaderRecord::Mute { + lanes, + muted, + smoothing_samples: window, + }, + ); + }); +} + +/// The output of `rig` over `frames`, with `script` called before each block (it reads +/// `rig.rendered` and `rig.quantum`). +fn scripted(mut rig: Rig, frames: u64, script: &dyn Fn(&mut Rig)) -> [Vec; 2] { + while rig.rendered < frames { + script(&mut rig); + rig.block(); + } + rig.out.map(|mut plane| { + plane.truncate(frames as usize); + plane + }) +} + +/// [`scripted`] on a live rig of `model` at each quantum of `quanta` (every time the script acts +/// at is a block entry of each), the same bits at each, and on the scalar oracle at the first +/// (with `host-core/test-support`); returns the first. +fn scripted_across( + model: &SessionModel, + quanta: &[u32], + frames: u64, + script: &dyn Fn(&mut Rig), +) -> [Vec; 2] { + let at = |quantum: u32| { + let mut model = model.clone(); + model.quantum_frames = quantum; + model + }; + let first = scripted(Rig::live(&at(quanta[0])), frames, script); + for &quantum in &quanta[1..] { + let other = scripted(Rig::live(&at(quantum)), frames, script); + for plane in 0..2 { + assert_eq!( + first_difference(&first[plane], &other[plane]), + None, + "quantum {quantum} against {}: plane {plane}", + quanta[0] + ); + } + } + #[cfg(feature = "test-support")] + { + let oracle = scripted(oracle_rig(&at(quanta[0])), frames, script); + for plane in 0..2 { + assert_eq!( + first_difference(&first[plane], &oracle[plane]), + None, + "the scalar oracle: plane {plane}" + ); + } + } + first +} + +/// The floored arrival `a(n)` of track 0's fader in `model`'s plan. +fn arrival_of(model: &SessionModel) -> u64 { + let rig = Rig::new(model); + fader_arrival(&rig.prepared, model, 0) +} + +/// `model` with no automation, unmuted, and track 0's fader static at `db` on both lanes: the +/// plan whose lane holds `gain(db)` exactly. +fn static_at(model: &SessionModel, db: f32) -> SessionModel { + let mut model = model.clone(); + model.automation.clear(); + let fader = &mut model.tracks[0].fader; + (fader.left_db, fader.right_db) = (db, db); + (fader.left_mute, fader.right_mute) = (false, false); + model +} + +/// The `Set` events the probe shows in the block that holds render sample `sample`, on a live +/// rig of `model` (at its quantum) with `script` called before each block: `(channel, render +/// sample, value bits)`. +fn sets_in_block_of( + model: &SessionModel, + script: &dyn Fn(&mut Rig), + sample: u64, +) -> Vec<(usize, u64, u32)> { + let mut rig = Rig::live(model); + while rig.rendered + rig.quantum as u64 <= sample { + script(&mut rig); + rig.block(); + } + let _ = builtins_compiler::test_only_take_fader_events(); + script(&mut rig); + rig.block(); + let (events, overflow) = builtins_compiler::test_only_take_fader_events(); + assert!(!overflow, "the probe holds every event"); + events + .iter() + .filter(|event| event.kind == automation::EventKind::Set) + .map(|event| (event.channel, event.sample, event.value.to_bits())) + .collect() +} + +/// #1647 batch 13b constraint 7: the move at `E` replaces the gain operation the cell gives at +/// `E`. A step exactly at `E` (to `-15` dB at timeline 1,000) after a fade the unmute ramp +/// remembers, and a grid point exactly at `E` (an unmute of 1,024 on a fade): in each the lane +/// takes the move alone, the same bits at every quantum and on the oracle. +/// +/// Test value: red if the cell's event at `E` is pushed beside the move (the stage's sink refuses +/// the second operation at one offset, so the move is lost; a debug build fails the refusal's +/// `debug_assert`), or if the move is not the landing at `E`. +#[test] +fn the_move_at_the_unmute_ramps_end_replaces_the_cells_event_there() { + const FRAMES: u64 = 2_048; + let jump = 480; + let (model, entry) = muted_ride( + vec![ + segment(AutomationShape::Linear, 0, 900, 0.0, -6.0), + segment(AutomationShape::Step, 900, 1_000, -6.0, -15.0), + ], + 10.0, + ); + let out = gate_eight(&model, &entry, FRAMES, None); + assert_move(&out, jump, &[retarget(-15.0, jump)], "a step at E"); + assert_settles(&out, jump, -15.0, "a step at E"); + + const E: u32 = 1_024; + let (model, entry) = muted_ride(fade_from(-30.0, 10_000), 10.0); + let out = scripted_across(&model, &QUANTA, FRAMES, &|rig| { + if rig.rendered == 0 { + live_mute(rig, builtins::BuiltinLaneSelector::Both, false, E); + } + }); + let target = curve(&entry, i64::from(E + jump) - 1); + for plane in &out { + assert_move_at( + plane, + E as usize, + jump, + &move_operations(&entry, i64::from(E), target, jump), + "a grid point at E", + ); + } +} + +/// A linear dB fade from `from` to `0` dB over `[0, end]`. +fn fade_from(from: f32, end: u64) -> Vec { + vec![segment(AutomationShape::Linear, 0, end, from, 0.0)] +} + +/// R17-10 (MAJOR-5 of the U2 review): a live write that frees a held lane discharges the +/// obligation and releases the cell's hold in the same block. The track is prepared muted on a +/// fade from `-30` to `0` dB; a live unmute of 4,000 samples is drained at 0, and a live unmute of +/// window 0 at 3,200 ends it there. From 3,200 the lane follows the curve: at each completion +/// sample `64k + 63` it equals the curve's gain exactly, at quantum 128, 100 and 64 and on the +/// oracle. +/// +/// Test value: red if the cell keeps its hold of the ended ramp (the lane stays at the gain the +/// live write left, about `-28.5` dB, while the curve moves, until the hold's last grid point), or +/// if the release is made without the discharge. +#[test] +fn a_live_write_that_frees_a_held_lane_releases_its_hold() { + const FRAMES: u64 = 4_800; + let (model, entry) = muted_ride(fade_from(-30.0, 48_000), 10.0); + let out = scripted_across(&model, &[128, 100, 64], FRAMES, &|rig| match rig.rendered { + 0 => live_mute(rig, builtins::BuiltinLaneSelector::Both, false, 4_000), + 3_200 => live_mute(rig, builtins::BuiltinLaneSelector::Both, false, 0), + _ => {} + }); + for plane in &out { + for t in (3_200 + 63..FRAMES).step_by(64) { + assert_eq!( + plane[t as usize].to_bits(), + gain(curve(&entry, t as i64)).to_bits(), + "completion sample {t}" + ); + } + } +} + +/// `model` with a true-peak limiter (5 ms lookahead) on track 0, and its fader's `a(n)`. +fn latent(model: SessionModel) -> (SessionModel, u64) { + let model = with_limiter(model, 0); + let arrival = arrival_of(&model); + assert!(arrival > 0, "the limiter delays the fader"); + (model, arrival) +} + +/// R17-8: a seek `Set` that lands exactly at `E` sets the lane there, with no ramp, and +/// discharges the obligation. Behind a latent insert (`a(n)` = λ) the seek at render 3,200 (to +/// timeline 8,000) reaches the fader at `3,200 + λ`, and the unmute of `3,200 + λ` samples drained +/// at 0 ends there, inside a block at quantum 128, 100 and 64. The probe shows one `Set` per +/// channel at `E`, and from `E` the output equals the plan whose fader is static at the curve's +/// value at 8,000 (until the next grid point), the same bits at each quantum and on the oracle. +/// +/// Test value: red if the seek's `Set` at `E` is replaced by the move (a ramp over `J` to the +/// same value, settled only at `E + J - 1`). +#[test] +fn a_seek_set_exactly_at_the_unmute_ramps_end_sets_the_lane() { + const FRAMES: u64 = 4_480; + const T: u64 = 8_000; + let (model, entry) = muted_ride(fade_from(-30.0, 48_000), 10.0); + let (model, arrival) = latent(model); + let e = 3_200 + arrival; + let script = |rig: &mut Rig| { + if rig.rendered == 0 { + live_mute(rig, builtins::BuiltinLaneSelector::Both, false, e as u32); + } + if rig.rendered == 3_200 { + rig.seek(T); + } + }; + let out = scripted_across(&model, &[128, 100, 64], FRAMES, &script); + let value = curve(&entry, T as i64).to_bits(); + assert_eq!( + sets_in_block_of(&model, &script, e), + [(0, e, value), (1, e, value)], + "a Set per channel at E" + ); + let reference = scripted( + Rig::new(&static_at(&model, curve(&entry, T as i64))), + FRAMES, + &|rig| { + if rig.rendered == 3_200 { + rig.seek(T); + } + }, + ); + let e = e as usize; + for plane in 0..2 { + assert_eq!( + first_difference(&out[plane][e..e + 64], &reference[plane][e..e + 64]), + None, + "plane {plane}: exact from E" + ); + } +} + +/// #1647 gate 4 behind a latent insert (MAJOR-4 of the U2 review): a session seek to 24,000 at +/// render 3,200 reaches a fader behind a true-peak limiter at `r = 3,200 + a(n)`, inside a node +/// block at quantum 128 and 100. The probe shows one `Set` per channel at `r` with the curve's +/// value; before `r` the output is the unseeked run's, and from `r` to the next grid point it +/// equals the plan whose fader is static at the curve's value at 24,000, the same bits at both +/// quanta. +/// +/// Test value: red if a seek inside a node block lands at the block's first sample instead of its +/// step, or ramps. +#[test] +fn a_session_seek_sets_a_latent_fader_lane_exactly_where_it_reaches_the_node() { + const FRAMES: u64 = 4_480; + const T: u64 = 24_000; + let mut model = plain(1, 128); + let entry = fader_entry(&model, 0, ParameterChannel::Both, fade()); + model.automation = vec![entry.clone()]; + let (model, arrival) = latent(model); + let r = 3_200 + arrival; + let seek = |rig: &mut Rig| { + if rig.rendered == 3_200 { + rig.seek(T); + } + }; + let out = scripted_across(&model, &[128, 100], FRAMES, &seek); + let value = curve(&entry, T as i64).to_bits(); + assert_eq!( + sets_in_block_of(&model, &seek, r), + [(0, r, value), (1, r, value)], + "a Set per channel at r" + ); + let unseeked = scripted(Rig::live(&model), FRAMES, &|_| {}); + let reference = scripted( + Rig::new(&static_at(&model, curve(&entry, T as i64))), + FRAMES, + &seek, + ); + let r = r as usize; + for plane in 0..2 { + assert_eq!( + first_difference(&out[plane][..r], &unseeked[plane][..r]), + None, + "plane {plane}: before r" + ); + assert_eq!( + first_difference(&out[plane][r..r + 64], &reference[plane][r..r + 64]), + None, + "plane {plane}: exact from r" + ); + } +} + +/// #1647 gate 8 with a seek step after `E` in `E`'s block (MAJOR-4 of the U2 review): behind a +/// latent insert, the unmute of 1,000 samples ends at `E` = 1,000, and a session seek (to 8,000) +/// at a block entry `r` reaches the fader at `r + a(n)`, after `E` in the same block (quantum 100: +/// `E` at the block's first sample; quantum 37: inside it). The lane moves at `E`, and from the +/// seek's step it is set exactly: the probe shows its `Set` there, and the output equals the plan +/// whose fader is static at the curve's value at 8,000, on the bank and on the oracle. +/// +/// Test value: red if the cut at `E` drops a seek step that lies after it in the block. +#[test] +fn a_seek_step_after_the_unmute_ramps_end_in_its_block_still_lands() { + const FRAMES: u64 = 2_048; + const T: u64 = 8_000; + let (model, entry) = muted_ride(fade_from(-30.0, 48_000), 10.0); + let (model, arrival) = latent(model); + let e = u64::from(UNMUTE); + for quantum in [100_u32, 37] { + let q = u64::from(quantum); + let block_end = (e / q + 1) * q; + let r = (0..=e / q) + .map(|k| k * q) + .find(|&r| r + arrival > e && r + arrival < block_end) + .expect("a block entry whose seek reaches the fader after E in E's block"); + let step = r + arrival; + let script = move |rig: &mut Rig| { + if rig.rendered == 0 { + live_mute(rig, builtins::BuiltinLaneSelector::Both, false, UNMUTE); + } + if rig.rendered == r { + rig.seek(T); + } + }; + let out = scripted_across(&model, &[quantum], FRAMES, &script); + let value = curve(&entry, T as i64).to_bits(); + let mut at_quantum = model.clone(); + at_quantum.quantum_frames = quantum; + assert_eq!( + sets_in_block_of(&at_quantum, &script, step), + [(0, step, value), (1, step, value)], + "quantum {quantum}: a Set per channel at the step" + ); + let mut reference_model = static_at(&model, curve(&entry, T as i64)); + reference_model.quantum_frames = quantum; + let reference = scripted(Rig::new(&reference_model), FRAMES, &|rig| { + if rig.rendered == r { + rig.seek(T); + } + }); + let step = step as usize; + for plane in 0..2 { + assert_ne!( + out[plane][step - 1].to_bits(), + reference[plane][step - 1].to_bits(), + "quantum {quantum}: the move ramps before the step" + ); + assert_eq!( + first_difference( + &out[plane][step..step + 64], + &reference[plane][step..step + 64] + ), + None, + "quantum {quantum}, plane {plane}: exact from the step" + ); + } + } +} + +/// The fader gain of `db` composed with the VCA offset sum `offsets` (#1646 D1). +fn composed(db: f32, offsets: f64) -> f32 { + builtins::checked_fader_gain(session::vca_compose_db(db, offsets)).expect("fader domain") +} + +/// #1647 D1.4 (MAJOR-3 of the U2 review): every event of a moving curve on a VCA member is +/// composed with the lane's offset sum `S`. Gate 1's fade on a track a VCA at `-6` dB reaches: +/// at each completion sample `64k + 63` the output equals +/// `checked_fader_gain(vca_compose_db(curve, S))` exactly, and every sample equals the stage driven +/// directly with the composed grid retargets. +/// +/// Test value: red if render converts an event's value without `S` (the member's ride would +/// ignore its VCA once the curve moves), or composes it otherwise than the static path. +#[test] +fn a_vca_offset_composes_with_every_fader_event() { + const FRAMES: u64 = 8_192; + const OFFSET: f32 = -6.0; + let mut model = plain(1, 128); + model.control_smoothing = smoothing(0.0); + let entry = fader_entry(&model, 0, ParameterChannel::Both, fade()); + model.automation = vec![entry.clone()]; + model.vcas = vec![session::Vca { + id: id("vca-ride"), + fader: session::DualMonoFader { + left_db: OFFSET, + right_db: OFFSET, + left_mute: false, + right_mute: false, + }, + members: vec![model.tracks[0].id.clone()], + }]; + let offsets = f64::from(OFFSET); + let mut rig = Rig::new(&model); + rig.until(FRAMES); + let mut held = curve(&entry, 0); + let operations: Vec<_> = (0..FRAMES) + .step_by(64) + .filter_map(|at| { + let value = curve(&entry, at as i64 + 63); + (value.to_bits() != held.to_bits()).then(|| { + held = value; + ( + at, + builtins::FaderOperation::RetargetGain { + gain: composed(value, offsets), + samples: 64, + }, + ) + }) + }) + .collect(); + let expected = reference_from(composed(curve(&entry, 0), offsets), &operations, FRAMES); + for plane in &rig.out { + for t in (63..FRAMES).step_by(64) { + assert_eq!( + plane[t as usize].to_bits(), + composed(curve(&entry, t as i64), offsets).to_bits(), + "completion sample {t}" + ); + } + assert_eq!( + first_difference(&plane[..FRAMES as usize], &expected), + None, + "every sample is the stage's, composed with S" + ); + } +} + +/// #1647 batch 13b constraint 4 and departure 3 (MINOR-11 of the U2 review). +/// +/// - A ramp that ends muted keeps the obligation: a live mute of 300 samples at 0 on a fade the +/// muted lane remembers, the curve flat at `-12` dB through the later unmute ramp (drained at +/// 3,200 over 1,000 samples, so no event falls in it) and moving after `E` = 4,200. The lane +/// lands at `E`. +/// - A hold with nothing remembered ends with no move: prepared muted, a curve flat at `-6` dB up +/// to 1,100 and moving after it, unmuted over 1,000 samples at 0. From `E` the lane renders +/// what the session prepared unmuted renders. +/// +/// The same bits at each quantum and on the oracle. +/// +/// Test value: red if a muted block entry discharges the obligation (no move at the later +/// unmute's end), or if a hold with nothing remembered moves the lane at its end. +#[test] +fn a_ramp_that_ends_muted_keeps_the_obligation_and_a_hold_with_nothing_remembered_does_not_move() { + let jump = 480; + let mut model = plain(1, 128); + model.control_smoothing = smoothing(10.0); + let entry = fader_entry( + &model, + 0, + ParameterChannel::Both, + vec![ + segment(AutomationShape::Linear, 0, 1_500, 0.0, -12.0), + segment(AutomationShape::Linear, 1_500, 4_300, -12.0, -12.0), + segment(AutomationShape::Linear, 4_300, 8_000, -12.0, -30.0), + ], + ); + model.automation = vec![entry.clone()]; + let e = 4_200_u32; + let out = scripted_across(&model, &[128, 100, 64], 5_120, &|rig| match rig.rendered { + 0 => live_mute(rig, builtins::BuiltinLaneSelector::Both, true, 300), + 3_200 => live_mute(rig, builtins::BuiltinLaneSelector::Both, false, e - 3_200), + _ => {} + }); + let target = curve(&entry, i64::from(e + jump) - 1); + for plane in &out { + assert_eq!( + plane[3_199].to_bits(), + 0.0_f32.to_bits(), + "muted until 3,200" + ); + assert_move_at( + plane, + e as usize, + jump, + &move_operations(&entry, i64::from(e), target, jump), + "ends muted", + ); + } + + const FRAMES: u64 = 2_048; + let (model, _) = muted_ride( + vec![ + segment(AutomationShape::Linear, 0, 1_100, -6.0, -6.0), + segment(AutomationShape::Linear, 1_100, 6_000, -6.0, -30.0), + ], + 10.0, + ); + let out = gate_eight(&model, &model.automation[0], FRAMES, None); + let mut unmuted_model = model.clone(); + ( + unmuted_model.tracks[0].fader.left_mute, + unmuted_model.tracks[0].fader.right_mute, + ) = (false, false); + let mut free = Rig::new(&unmuted_model); + free.until(FRAMES); + let e = UNMUTE as usize; + for (plane, (out, free)) in out.iter().zip(&free.out).enumerate() { + assert_eq!( + first_difference(&out[e..], &free[e..FRAMES as usize]), + None, + "nothing remembered: plane {plane} renders the free lane from E" + ); + } +} + +/// #1647 batch 13b, dual mono (MINOR-11 of the U2 review): a track prepared with its left lane +/// muted and both lanes on one fade; a live unmute of 1,000 samples on the left lane only, at 0. +/// The left lane lands at `E`; the right lane, free all along, renders the stage driven directly +/// with the grid's retargets, untouched by the left lane's hold. The same bits at each quantum and +/// on the oracle. +/// +/// Test value: red if one channel's hold or obligation is read for the other (the free right lane +/// would be moved at the left lane's `E`). +#[test] +fn a_left_only_unmute_moves_the_left_lane_alone() { + const FRAMES: u64 = 2_048; + let jump = 480; + let mut model = plain(1, 128); + model.control_smoothing = smoothing(10.0); + model.tracks[0].fader.left_mute = true; + let entry = fader_entry(&model, 0, ParameterChannel::Both, fade_from(-30.0, 10_000)); + model.automation = vec![entry.clone()]; + let out = scripted_across(&model, &QUANTA, FRAMES, &|rig| { + if rig.rendered == 0 { + live_mute(rig, builtins::BuiltinLaneSelector::Left, false, UNMUTE); + } + }); + let target = curve(&entry, i64::from(UNMUTE + jump) - 1); + assert_move_at( + &out[0], + UNMUTE as usize, + jump, + &move_operations(&entry, i64::from(UNMUTE), target, jump), + "left", + ); + let free = reference(-30.0, &grid_operations(&entry, FRAMES), FRAMES); + assert_eq!( + first_difference(&out[1], &free), + None, + "the right lane renders the grid alone" + ); +} + +/// #1648b (the U2 review's carry finding, required gate): a lane that gains automation on a +/// carried stage moves from the stage's gain, not from its prepared seed. The predecessor holds +/// track 1's fader static at `-20` dB; the successor, swapped in at block 6 with the stage +/// carried, adds a ride flat at `0` dB (its prepared value, the seed). With no seek, the lane +/// jumps from `-20` to `0` dB over the fader jump length, and from the block after that jump +/// every block equals the successor rendered fresh from sample 0. +/// +/// Test value: red if adoption compares the curve with the cell's seed instead of the gain the +/// carried stage holds (the lane would keep `-20` dB under a ride at `0` dB, every block). +#[cfg(feature = "test-support")] +#[test] +fn a_ride_gained_on_a_carried_static_fader_jumps_from_the_stages_gain() { + use successor::{Feed, QUANTUM, SWAP_BLOCK, Session, Successor, reference_run, swapped_run}; + let mut base = plain(2, 128); + base.control_smoothing = smoothing(5.0); + (base.tracks[1].fader.left_db, base.tracks[1].fader.right_db) = (-20.0, -20.0); + let mut ride = base.clone(); + ride.automation = vec![flat_fader(&ride, 1, ParameterChannel::Both, 0.0)]; + let a = Session::compile(base); + let b = Session::compile(ride); + let feed = Feed::new("fixture-source", 7); + let settled = SWAP_BLOCK + 240_usize.div_ceil(QUANTUM) + 1; + assert!(settled < successor::BLOCKS); + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + let swapped = swapped_run(&a, &b, &[&feed], &[&feed], backend, Successor::Carry); + assert!( + swapped.restarted.is_empty(), + "{backend:?}: the stage carries" + ); + let reference = reference_run(&b, &[&feed], backend); + assert_ne!( + swapped.blocks[SWAP_BLOCK].bits, reference[SWAP_BLOCK].bits, + "{backend:?}: the swap block ramps from -20 dB" + ); + for (block, (swapped, reference)) in swapped + .blocks + .iter() + .zip(&reference) + .enumerate() + .skip(settled) + { + assert_eq!(swapped.bits, reference.bits, "{backend:?}: block {block}"); + } + } +} + #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] mod canonical_preparation { use super::*; diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index e8ccec79d..49c85e1e7 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -293,7 +293,11 @@ the browser alike (#1647): the lane follows its curve in node time, reaching the the end of each 64-sample retarget on a fixed grid, taking each discontinuity at its exact sample over the session's fader ramp length (`control_smoothing.fader_ms`), lined up with the audio it acts on through every latent insert, and set exactly, with no ramp, where a session seek reaches -the fader. The output does not depend on the render quantum or on where a seek landed. Stored +the fader. One exception: while a mute or unmute ramp holds the lane's gain, the lane only +remembers each value it is given, a seek's included, and where the hold ends with the gain free it +ramps over the fader ramp length to the curve at that ramp's end (a seek that reaches the fader +exactly where the hold ends still sets it exactly). The output does not depend on the render +quantum or on where a seek landed. Stored automation has no queue: render generates its events from the plan. An entry on any other row, for the strip, a console slot or an insert, is valid and inert: it authors and round-trips and renders nothing until its row's slice lands. Rendering the whole table identically on every From 6fcd46e01bb364953be6fcb21730d0b69a310271 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 21:00:30 +0000 Subject: [PATCH 22/35] Add placeholder TBD49: index automation entries per strip once per build #1650b's successor note: list and catalog builds scan the automation table once per strip. Control-thread cost only; for the weekly performance pass. Co-Authored-By: Claude Opus 5.5 --- ...mation-entries-per-strip-once-per-build.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/ISSUE_SPECS/TBD49-index-automation-entries-per-strip-once-per-build.md diff --git a/.github/ISSUE_SPECS/TBD49-index-automation-entries-per-strip-once-per-build.md b/.github/ISSUE_SPECS/TBD49-index-automation-entries-per-strip-once-per-build.md new file mode 100644 index 000000000..43645c38b --- /dev/null +++ b/.github/ISSUE_SPECS/TBD49-index-automation-entries-per-strip-once-per-build.md @@ -0,0 +1,36 @@ +# Index the automation entries per strip once per build + +Placeholder from batch 17 (#1650b's successor note; controls coordinator, 2026-10-16). Root files +it for the weekly performance pass. It is control-thread cost only; render is not touched. + +## Product outcome + +Building the control list and the control catalog costs O(strips + entries) in the automation +table, not O(strips × entries). The rows, their values and the `automated` flag do not change. + +## Context + +- Since #1650b, a strip's `fader_db` row reads the offset of the rendered automation entry that + drives its lane, and the list and catalog carry an `automated` flag per row (decision 17 rows in + `crates/controls`; catalog in `crates/host-core/src/control_provider.rs`, `ControlCatalog::build` + and `stage_live`). +- Each strip finds its driving entries by a scan of the whole `automation` table + (`controls::automated_fader_lanes`, `fader_automation`, and the row bindings), so a build over a + session with many strips and many entries scans the table once per strip. +- #1651 to #1667 add more automated rows (mute, pan, matrix, sends, trim, effects); each one that + binds the same way multiplies the cost. + +## Decisions to make at filing + +- D1. One pass over the table per build (and per staged live edit when the owners model holds the + entries) builds a per-owner index of the rendered entries that drive each control address; every + row binding reads the index. The index is built on the control thread, before any reply, and is + charged where the catalog's transient is charged (#1400b). +- D2. Gate: a session of 256 strips with one rendered fader entry each builds the list with the + table walked once (a counter or a bound on comparisons), and the list bytes equal the unindexed + build's bytes. + +## Dependencies + +- #1650 (the binding and the flag). Measure first in the weekly pass; no change unless the + measured build time of a large session warrants it. From 8a5c4e9ccab251ab46c45a598bcc0a65037a0183 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 21:11:02 +0000 Subject: [PATCH 23/35] Gate the catalog term of the #1646 charge test on control-provider #1650b's heap cross-check read PreparedHost::control_catalog, which exists only with host-core's control-provider feature; the default and no-default clippy legs did not compile. Without the feature the catalog term is 0. Co-Authored-By: Claude Opus 5.5 --- crates/host-core/tests/stored_fader_automation.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index bc346de50..8214fb3de 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -197,9 +197,13 @@ fn a_fader_automation_is_charged_to_the_graph_plan_rows_and_the_cap() { } // The control catalog charges its own bytes: the automated lane's `fader_db` row carries - // `automated` (#1650 D4), a descriptor of its own, which the catalog's resources count. + // `automated` (#1650 D4), a descriptor of its own, which the catalog's resources count. The + // catalog exists only with `control-provider`. + #[cfg(feature = "control-provider")] let catalog = with.control_catalog.resources().retained_bytes - base.control_catalog.resources().retained_bytes; + #[cfg(not(feature = "control-provider"))] + let catalog = 0; assert_eq!( with_retained - base_retained - catalog, charge, From 86271e0968b26c99daea67b8180a2aaed780d208 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 21:24:39 +0000 Subject: [PATCH 24/35] Release a held cell after any held block; remove D1.2 (#1647 U2 fold 2) MAJOR-A of the U2 fold verification: a live write that frees a lane with no move owed left the cell's D1.2 hold, so later grid events were only remembered and the lane went stale. The cell's hold is now released at any free block entry after a block the stage held through, owed or not (gate: probe red on 6fcd46e01). D1.2 removed: unobservable after R17-9 and R17-10 (mutation run: all gates green without it). One-line unreachability proofs at every debug_assert site of the fader event driver; release paths hold or skip. Lane slot 224 B; realtime policy 160; pcm_digest unchanged. Attempt 3 of 5. Co-Authored-By: Claude Opus 5.5 --- ...7-render-moving-stored-fader-automation.md | 78 +++++++- crates/builtins-compiler/src/lib.rs | 176 +++++++----------- .../tests/stored_fader_automation.rs | 39 +++- 3 files changed, 176 insertions(+), 117 deletions(-) diff --git a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md index c2d71d3fe..02aa65139 100644 --- a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md @@ -60,6 +60,7 @@ exactly as before. The fader row leaves the classifier's mask in #1648, in the s A gain the conversion refuses is unreachable (#1645 bounds the curve to the fader domain and `vca_compose_db` clamps, #1646 D6); it keeps the current target, adds one to a render counter and fails a `debug_assert`. + *(Superseded by the U2 fold, MINOR-7: the counters were deleted; the condition is unreachable, keeps the stage's target and fails its `debug_assert`.)* - **D2. First blocks.** A fresh plan starts every automated lane at its prepared value (#1646 D2), so its first block emits no `Set`. A lane a successor adds or restarts takes a `Set` at its first block when the curve at that block's node time differs from its prepared bits (README A1.4, @@ -514,6 +515,7 @@ is #1647c. `RememberGain`, `Set` -> `SetGain`. A refused conversion or sink push keeps the target, adds one to the lane's `refused_events` and fails a `debug_assert` (unreachable). `finish` consumes any event a stopped stage did not pull, so the cell's state still advances. + *(Superseded by the U2 fold, MINOR-7: the counters were deleted; the condition is unreachable, keeps the stage's target and fails its `debug_assert`.)* - **Fader bank.** `process` renders through `BuiltinFaderBank::process_with(driver)` when the bank has automation, else the old `process` (a session without automation runs the same code). The fused fader-matrix bank fuses through `try_process_settled_with_matrix_and_operations`, so it @@ -557,6 +559,7 @@ is #1647c. automated lanes). A bank field would grow `size_of::()` for every session and move `fixtures/graph/v1/direct-route.resources.json` (#1647a's finding); the slot is paid only by automated sessions. They are read off render; no qualification row is added. + *(Superseded by the U2 fold, MINOR-7: the counters were deleted; the condition is unreachable, keeps the stage's target and fails its `debug_assert`.)* 2. **The oracle's automated fader never fuses or defers** (the bank fuses when no event falls in the block). Both are exact; the oracle has no fused-with-operations form, and the parity gate below proves the bits equal. @@ -645,6 +648,7 @@ The held-ramp obligation (D1.6, batch 13b amendment, gate 8). - No new `debug_assert`: a refused move uses the existing refusal closure and counter (`refused_events`). The stage's `fader_operations_stopped` stays summed per lane (constraint 6, #1647b). `finish` still advances a cell through a cut the stage did not reach (no move). + *(Superseded by the U2 fold, MINOR-7: the counters were deleted; the condition is unreachable, keeps the stage's target and fails its `debug_assert`.)* **Departures.** @@ -715,9 +719,11 @@ These amend the batch 13b amendment and gate 8 above; the filed text stays as it the gain is free from `E`, so it sets the lane there exactly (D3), and it discharges the obligation. Constraint 5 ("a seek `Set` inside a held ramp lands by the move") is for a seek before `E`. -- **Constraint 4 (R17-10).** A live write that frees the gain at a block entry discharges an owed - obligation and, in the same block, releases the cell's hold (D1.2's report, or the cell's own - ramp end), so the grid moves the lane from that block's first sample. +- **Constraint 4 (R17-10).** A live write that frees, at a block entry, a gain the stage held + through the end of the previous block discharges an owed obligation and, in the same block, + releases the cell's hold, whether or not a move was owed (U2 fold 2), so the grid moves the lane + from that block's first sample. A hold that ends by itself needs no release: the cell's hold of + it ends at the same sample. - **Gate 8 (e).** "Samples `[E, E + J - 1]` equal the stage driven directly with one `RetargetGain` at `E` over `J`" reads: with one `RetargetGain` at `E` over `J` to the curve at `E + J - 1`, and the one grid retarget #1637 commits in that ramp's last 64 samples (the grid point at @@ -726,6 +732,7 @@ These amend the batch 13b amendment and gate 8 above; the filed text stays as it - **Gate 8's test value.** "Red if D1.2 refreshes `hold_until` inside the hold" is withdrawn: under R17-9 the landing at `E` rewrites the whole cell state, and before `E` the stage only remembers, so neither a refresh nor a missing D1.2 report reaches the output (U2 fold, finding MAJOR-1). + D1.2 itself is removed (U2 fold 2). ## U2 fold (batch 17) @@ -740,8 +747,10 @@ once against the mutation the review found green (or the stated revert), then th refreshed at every held block entry). Reason: before `E` the stage only remembers every gain operation, a refresh only moves the cell's state, and the R17-9 landing at `E` rewrites all of that state (cursor, target, held flag, ramp end). Without a move nothing was remembered, so the - hold changes nothing; after a live write R17-10 releases the hold. The test was deleted (it - defended nothing); D1.2 is kept per R17-10. Gate 8's test-value clause is withdrawn above. + hold changes nothing, except when a live write frees the lane early (U2 fold 2: R17-10 then + releases the hold at the entry of the block whose write freed a gain the stage held through the + previous block, owed or not). The test was deleted (it defended nothing); D1.2 was kept here and + is removed in U2 fold 2. Gate 8's test-value clause is withdrawn above. - **MAJOR-2 (constraint 7).** `the_move_at_the_unmute_ramps_end_replaces_the_cells_event_there`: a step exactly at `E` (to `-15` dB at 1,000 after a remembered fade), and a grid point at `E` (an unmute of 1,024 on a fade); 4 quanta and the oracle. Red (the test process aborts on the @@ -759,7 +768,8 @@ once against the mutation the review found green (or the stated revert), then th latent insert, quanta 100 and 37 with the oracle: the seek's `Set` after `E` in `E`'s block); red with `hold_end` dropping a step after `E`. - **MAJOR-5 / R17-10 (render fix).** `HeldRampObligation::block` returns whether a free block - entry discharged an owed obligation; `FaderEventDriver::new` then reports a hold that ended + entry discharged an owed obligation (U2 fold 2: whether a write freed a gain held through the + previous block, owed or not); `FaderEventDriver::new` then reports a hold that ended before the block (`hold_until(node(0) - 1)`), which releases the cell's hold. One driver, so the bank, the fused bank and the scalar oracle all take it. `a_live_write_that_frees_a_held_lane_releases_its_hold` (a 4,000-sample unmute ended by a @@ -832,3 +842,59 @@ regions in 33 files (unchanged). fmt; clippy `-D warnings` (builtins-compiler, h all targets, all features); no-feature `cargo check --all-targets` on the same: clean. `./target/release/audit capi`: `pcm_digest` `bffc21e857b5e134` (unchanged), 0 violations, 0 allocations. + +## U2 fold 2 (batch 17) + +The U2 fold's review (MAJOR-A, MINOR-B, a NIT) and root's ruling on D1.2, folded on +`codex/controls-batch-17k` over `6fcd46e01`. Uncommitted for the coordinator. + +- **MAJOR-A (render fix: the held-through release).** R17-10 released the cell's hold only when a + move was owed, so a live write that freed the lane with nothing owed left the cell holding (D1.2's + report of the ended ramp): later grid events became `RememberGain` on a free stage and the lane + stayed stale. `HeldRampObligation` now records `held_through` (the stage held the gain through the + last sample of the block just rendered); `block` returns a release at any free block entry after + such a block with no move due, owed or not, and the driver releases the cell's hold there + (`hold_until(node(0) - 1)`). A hold that ended by itself (inside the previous block, or on its last + sample: `due`) needs no release. The other places that copy the stage's hold into the cell (the + held branch of `offsets_jump`, the adoption reposition) take the same release: both copy a stage + ramp, which either ends inside the block (the cell's hold ends with it) or holds through the + block's end (and so is released at the next free entry). One driver: the bank, the fused bank and + the scalar oracle all take it. Gate + `a_live_write_that_frees_a_lane_with_nothing_owed_releases_its_hold` (prepared muted; curve flat at + `-30` dB over `[0, 2_000]`, then a fade to `0` dB at 10,000; a live unmute of 4,000 at 0 and of + window 0 at 1,024: every completion sample from 1,087 is the curve's gain; quanta 128 and 64 and + the oracle). Red on `6fcd46e01` (completion sample 2,047 off the curve), checked once. +- **D1.2 removed: unobservable after R17-9 and R17-10.** While the stage holds the lane it only + stores gain operations, and the cell emits the same events with or without the report, so `owed` + is set alike. If a move is owed, the R17-9 landing at `E` rewrites the cell's whole state; if a + live write frees the lane early, the held-through release ends the hold; if nothing is owed and + the hold ends by itself, it ends at `E - 1`, where the report's hold ended too. So + `LaneFaderAutomation::report_held_ramp`, `report_held_ramps`, the drain's held marks + (`drain_fader_controls_marking`, folded back into `drain_fader_controls`), the scalar oracle's + marks and the now unused `selector_channels` are deleted. Mutation check (once): with the fix in + and D1.2 removed every gate stays green (`stored_fader_automation` 27/27, and the full check set + below). This withdraws constraint 1's report (batch 13b amendment) and the "D1.2 is kept" of the + U2 fold above. +- **MINOR-B.** Each unreachable `debug_assert` condition in the driver carries a one-line proof and + its release action (a hold or a skip; no panic; no NaN reaches the stage): `read_offsets`'s + offsets-jump refusal, `cut`'s span, the driver's missing program cell, its span / slot / `hold_end` + refusal, `apply`'s push and moving cut, and the three `process_with` stop sites. +- **NIT.** D1's render counter, the #1647b driver bullet, #1647b departure 1 and the #1647c + "No new `debug_assert`" bullet now carry a superseded note (the counters were deleted by the U2 + fold's MINOR-7). + +**Measured (x86-64, AVX2).** `lane_slot_bytes` 224 (unchanged: the new flag fits the padding). No +fixture moved; `pcm_digest` `bffc21e857b5e134` unchanged. + +**Checks (x86-64 AVX2).** `cargo test --locked -p automation -p graph -p builtins -p +builtins-compiler --features builtins-compiler/test-support,graph/test-support`: 388 pass. +`-p host-core --all-features`: 556 pass (`stored_fader_automation` 27; 24 without features). +`-p capi`: 206 pass. `-p host-web --features host-web/test-support --lib --test control_parity`: +173 pass. `realtime-policy`: ok, 160 regions in 33 files (unchanged). fmt; clippy `-D warnings` +(builtins-compiler, host-core; all targets, all features); no-feature `cargo check --all-targets`: +clean. `./target/release/audit capi`: `pcm_digest` `bffc21e857b5e134`, 0 violations, 0 +allocations. No edit under `crates/engine/src/realtime/`, `crates/automation` or `crates/builtins`. + +**Attempt count (AGENTS.md five-attempt rule).** #1647 is at attempt 3 of 5: the implementation +(#1647a-c), the U2 fold (59ca8e3b4) and U2 fold 2 (this section). A failed verification of fold 2 +goes to root before attempt 4. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 0749aaf6e..551acbb7a 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -800,11 +800,16 @@ struct HeldRampObligation { /// A gain event the stage only remembered is outstanding. Kept through a ramp that ends /// muted (constraint 4); cleared by the move, by a seek `Set` exactly at the hold's end /// (R17-8), or when a live write freed the gain at a block entry (it discharges the - /// obligation there and the cell's hold is released, R17-10). + /// obligation there, R17-10). owed: bool, /// The held ramp ends on the last sample of the block just rendered: the move is due at the /// next block's first sample, which is the first sample the stage reports the gain free. due_at_entry: bool, + /// The stage held the gain through the last sample of the block just rendered. A next block + /// that finds the gain free with no move due had its hold cleared by a live write at its + /// entry, so the cell's hold of the ramp that write ended is released there, owed or not + /// (R17-10). + held_through: bool, } /// One strip lane's stored fader automation (#1646 D1): the plan's program and the lane's @@ -910,25 +915,6 @@ impl LaneFaderAutomation { self.fader_jump = fader_jump; } - /// D1.2: report to `channel`'s cell a held ramp the live drain started at this block's first - /// sample (a live mute or unmute ramp) of `remaining` samples, so its grid holds inside it. - /// The ramp completes at the node time of its last sample (`Completion::End`); with no span - /// or a node time past the `i64` range nothing is reported. - fn report_held_ramp(&mut self, channel: usize, remaining: u32) { - let Some(last) = remaining.checked_sub(1) else { - return; - }; - let Some(span) = self.span else { - return; - }; - if let (Some(Some(cell)), Some(end)) = ( - self.channels.get_mut(channel), - node_time_at(span, u64::from(last)), - ) { - cell.state.hold_until(end); - } - } - /// #1649 D3, at block entry under the block's `snapshot`: each automated channel whose /// offsets cell holds a newer write takes its `S` and ramp word, and the lane jumps to the /// composed gain at the jump's completion when that gain differs in bits from the lane's @@ -965,6 +951,11 @@ impl LaneFaderAutomation { }; let record = FaderOffsetsRecord::from_words(words); let jumped = cell.offsets_jump(program, *span, channel, record, stage); + // Unreachable refusal: the cell index is the program's own (prepared from it), the + // curve is in the fader domain and `vca_compose_db` clamps (a NaN `S`, which a + // validated record never carries, fails `checked_fader_gain`, so no NaN reaches the + // stage), and the lane is a populated member. In release a refusal leaves the stage's + // gain as it was: a hold, no panic. debug_assert!(jumped, "an offsets jump is refused at render"); } } @@ -1043,7 +1034,8 @@ impl AutomatedFaderCell { /// On a lane whose gain is held (muted, or in a mute or unmute ramp) the stage only remembers /// the gain (#1638 D2), so the target its ramp aims for is no longer one the cell knows: the /// cell forgets its target (a fresh state, whose next free grid event always retargets) and - /// keeps the held ramp's report. With no node time (a plan with no timeline) the cell's own + /// holds until the held ramp's last sample (released at the entry of a block whose live write + /// frees the gain early, R17-10). With no node time (a plan with no timeline) the cell's own /// target is the jump's value. /// /// `false` when the composed value has no fader gain, the program has no such cell, or the @@ -1159,15 +1151,6 @@ fn block_fader_jump(time: &GraphBlockTime<'_>) -> Option { }) } -/// The channels a live record's selector covers, as `[left, right]`. -const fn selector_channels(lanes: BuiltinLaneSelector) -> [bool; 2] { - match lanes { - BuiltinLaneSelector::Left => [true, false], - BuiltinLaneSelector::Right => [false, true], - BuiltinLaneSelector::Both => [true, true], - } -} - /// The most cells one fader owner drives in a block: two channels on each of a bank's lanes. const MAXIMUM_FADER_CELLS: usize = 2 * MAXIMUM_BANK_LANES; @@ -1270,6 +1253,9 @@ impl FaderCellCursor<'_> { landing = events.next(); state = events.into_state(); } + // Unreachable: `NodeSpan::new` errs only for a step offset at or past the span's + // length, and 0 < 1. In release the cut lands nothing (the move is skipped and + // the lane holds its gain), no panic. Err(_) => debug_assert!(false, "a one-sample span with its step at 0 is valid"), } } @@ -1305,8 +1291,8 @@ impl FaderCellCursor<'_> { /// (R17-9: aimed at the curve at `E + J - 1`, over the block's jump length `J`), in place of any /// event the cell gives at `E`; a seek `Set` exactly at `E` is pushed instead and discharges the /// obligation (R17-8). The channel's span is cut there, so no event past `E` is pulled before the -/// move. A live write that frees the gain at a block entry discharges the obligation and releases -/// the cell's hold there (R17-10). An event render refuses (unreachable: the curve is in the fader +/// move. A live write that frees a gain the stage held through the previous block discharges the +/// obligation and releases the cell's hold there, owed or not (R17-10). An event render refuses (unreachable: the curve is in the fader /// domain, `vca_compose_db` clamps and the lane is a member) keeps the stage's target and fails a /// `debug_assert`. Fixed size, on the render stack; allocation-, lock- and syscall-free. struct FaderEventDriver<'a> { @@ -1378,6 +1364,8 @@ impl<'a> FaderEventDriver<'a> { }; let refused = || debug_assert!(false, "an automated fader cell is refused at render"); + // Unreachable: the cell index was prepared from this same program. In release the + // channel is skipped: it emits nothing and the lane holds its gain, no panic. let Some(cell_program) = program.cell(*cell as usize) else { refused(); continue; @@ -1408,11 +1396,11 @@ impl<'a> FaderEventDriver<'a> { step = Some((0, span.start)); mode = automation::SeekMode::Ramp; } - let (free_from, at, discharged) = obligation.block(hold_now, frames); - if discharged && let Some(first) = span_time(span.start, step, 0) { - // R17-10: the live write that freed the gain discharged the obligation, so - // the cell's hold of the ramp it ended is released here: its grid moves the - // lane from this block's first sample on. + let (free_from, at, release) = obligation.block(hold_now, frames); + if release && let Some(first) = span_time(span.start, step, 0) { + // R17-10: a live write freed the gain the stage held through the previous + // block, so the cell's hold of the ramp it ended is released here, owed or + // not: its grid moves the lane from this block's first sample on. state.hold_until(first.saturating_sub(1)); } let end = at.map(|at| hold_end(span.start, step, at, frames)); @@ -1424,6 +1412,12 @@ impl<'a> FaderEventDriver<'a> { ), _ => automation::NodeSpan::new(span.start, step, frames), }; + // Unreachable refusal: the span is valid (a step kept only at or before `E` lies + // inside `E + 1` samples, and the graph's step lies inside the block); at most + // `MAXIMUM_FADER_CELLS` cursors are used (two channels per lane, and the const + // assert bounds every bank width by `MAXIMUM_BANK_LANES`); and `hold_end` is + // `Some` because the block's node times were checked against the `i64` edge above. + // In release the channel is skipped: it emits nothing and the lane holds its gain. let (Ok(node), Some(slot), false) = ( first, driver.cursors.get_mut(used), @@ -1507,12 +1501,16 @@ impl HeldRampObligation { /// The block's hold of one channel from its stage state `hold` after the live drain: the /// first offset at which the gain is free, `E` when the hold ends with the gain free in this /// block (a ramp that ends exactly at the block's end makes `E` the next block's first sample, - /// when a move is owed), and whether a live write discharged the obligation here. A channel - /// free at a block entry with no move due had its hold cleared by a live write there, which - /// discharges an owed obligation (constraint 4); the caller then releases the cell's hold - /// (R17-10). + /// when a move is owed), and whether the caller releases the cell's hold at the block's first + /// sample (R17-10). A channel the stage held through the end of the previous block and that + /// is free at this block's entry with no move due had its hold cleared by a live write there: + /// the write discharges an owed obligation (constraint 4), and the cell's hold of the ramp it + /// ended is released whether or not a move was owed. A hold that ended by itself (a ramp that + /// ended inside the previous block, or on its last sample) needs no release: the cell's hold + /// of it ended at the same sample. fn block(&mut self, hold: Option, frames: u32) -> (u32, Option, bool) { let due = core::mem::take(&mut self.due_at_entry); + let held_through = core::mem::take(&mut self.held_through); match hold { Some(ChannelHold { held: true, @@ -1524,12 +1522,19 @@ impl HeldRampObligation { (remaining, Some(remaining), false) } else { self.due_at_entry = remaining == frames; + self.held_through = true; (frames, None, false) } } - Some(ChannelHold { held: true, .. }) => (frames, None, false), + Some(ChannelHold { held: true, .. }) => { + self.held_through = true; + (frames, None, false) + } Some(_) if due && self.owed => (0, Some(0), false), - Some(_) => (0, None, core::mem::take(&mut self.owed)), + Some(_) => { + self.owed = false; + (0, None, held_through && !due) + } None => (0, None, false), } } @@ -1639,6 +1644,11 @@ impl builtins::FaderOperationSource for FaderEventDriver<'_> { let mut push = |cursor: &FaderCellCursor<'_>, event: automation::Event| { let pushed = Self::operation(event, cursor.offsets_sum) .is_some_and(|operation| sink.push(cursor.lane, selector, operation).is_ok()); + // Unreachable refusal: one cursor per lane and channel, and constraint 7 with + // R17-8 push one gain operation per cursor and offset (no `Duplicate`); the lane + // is a populated member; `vca_compose_db` clamps to the fader domain, so the + // conversion gives a finite gain. In release the event is dropped: the stage + // keeps its gain target (a hold, no NaN), no panic. debug_assert!(pushed, "a stored fader event is refused at render"); #[cfg(any(test, feature = "test-support"))] test_only_record_fader_event(TestOnlyFaderEvent { @@ -1672,6 +1682,10 @@ impl builtins::FaderOperationSource for FaderEventDriver<'_> { cursor.obligation.owed = false; match landing { Some(landing) => push(cursor, landing), + // Unreachable: a cursor's events are `Some` until its one cut (the cut clears + // `end`), the one-sample span is valid, and a `Ramp` seek step always lands + // (`land` returns an event). In release no move is pushed and the lane holds + // the gain the held ramp aimed for, no panic. None => debug_assert!(false, "a moving cut lands its cell"), } } @@ -2993,9 +3007,6 @@ pub fn test_only_take_fader_lane_times() -> (Vec, bool) { impl FaderBankProcessor { /// The block drain under `snapshot`: the body of `begin_block`. - /// - /// D1.2: a held ramp the drain starts on an automated lane is reported to its cell once, here, - /// at the block entry that starts it (#1647, batch 13b amendment, constraint 1). fn drain(&mut self, snapshot: LiveSnapshot) -> Result<(), RenderError> { let Self { bank, @@ -3003,11 +3014,7 @@ impl FaderBankProcessor { automation, .. } = self; - let mut held = [0_u8; 2]; - let result = drain_fader_controls_marking(bank, controls, snapshot, EVERY_LANE, &mut held); - if held != [0; 2] { - report_held_ramps(bank, automation, held); - } + let result = drain_fader_controls(bank, controls, snapshot, EVERY_LANE); read_fader_offsets(bank, controls, automation, snapshot); result } @@ -3187,7 +3194,11 @@ impl GraphPreparedBuiltinBankProcessor for FaderBankProcessor { first_sample, bank_hold(bank), ); - // A stop is a broken source contract: the stage fails its own `debug_assert`. + // A stop is unreachable: after `apply(offset)` every cursor has consumed its event and + // cut at `offset`, a piece's events rise strictly and a second piece starts at + // `E + 1`, so the next offset is above it. A stop (a broken source contract) fails the + // stage's own `debug_assert`; in release the block's rest renders with the gains held + // and `finish` advances every cell through the block, no panic. let _ = bank.process_with(left, right, frames, &mut driver); driver.finish(); } @@ -3263,25 +3274,6 @@ fn read_fader_offsets( ); } } - -/// D1.2: report each held ramp the block drain started on an automated lane (bit `lane` of -/// `held[channel]`) to that lane's cell, from the stage's remaining count. -fn report_held_ramps( - bank: &BuiltinFaderBank, - automation: &mut [Option], - held: [u8; 2], -) { - for (lane, slot) in automated_lanes(automation) { - for (channel, marks) in held.iter().enumerate() { - if lane_selected(*marks, lane) - && bank.gain_held(lane, channel) == Some(true) - && let Some(remaining) = bank.ramp_remaining(lane, channel) - { - slot.report_held_ramp(channel, remaining); - } - } - } -} // REALTIME_POLICY_END /// One strip matrix/pan bank and the live-control channels of its member lanes (issue #212). @@ -3741,18 +3733,6 @@ fn drain_fader_controls( controls: &mut [Option], snapshot: LiveSnapshot, lanes: u8, -) -> Result<(), RenderError> { - drain_fader_controls_marking(bank, controls, snapshot, lanes, &mut [0; 2]) -} - -/// [`drain_fader_controls`], also setting bit `lane` of `held[channel]` for each lane and channel -/// a drained mute record with a nonzero window started a held ramp on (#1647 D1.2). -fn drain_fader_controls_marking( - bank: &mut BuiltinFaderBank, - controls: &mut [Option], - snapshot: LiveSnapshot, - lanes: u8, - held: &mut [u8; 2], ) -> Result<(), RenderError> { let mut result = Ok(()); for (lane, control) in controls.iter_mut().enumerate() { @@ -3773,17 +3753,7 @@ fn drain_fader_controls_marking( FaderKind::Fader => { bank.set_fader_db(lane, lanes, f32::from_bits(words[0]), words[1]) } - FaderKind::Mute => { - if words[1] != 0 { - let bit = 1_u8.checked_shl(lane as u32).unwrap_or(0); - for (marks, covered) in held.iter_mut().zip(selector_channels(lanes)) { - if covered { - *marks |= bit; - } - } - } - bank.set_mute(lane, lanes, words[0] != 0, words[1]) - } + FaderKind::Mute => bank.set_mute(lane, lanes, words[0] != 0, words[1]), } }); result = result.and(drained.map_err(render_error)); @@ -4026,6 +3996,7 @@ impl GraphPreparedBuiltinBankProcessor for FaderMatrixBankProcessor { bank.process(left, right, frames); } Some(driver) => { + // A stop is unreachable, and safe in release (see `FaderBankProcessor`). let _ = bank.process_with(left, right, frames, driver); } } @@ -7374,6 +7345,7 @@ fn render_oracle_fader( }) }, ); + // A stop is unreachable, and safe in release (see `FaderBankProcessor`). let _ = fader.process_with(block, &mut driver); driver.finish(); } @@ -7611,12 +7583,8 @@ impl GraphRuntimeProcessor for LiveControlFaderProcessor { #[cfg(any(test, feature = "test-support"))] impl LiveControlFaderProcessor { /// The fader and mute cells' drain under the block's `snapshot` (#1312 D12). - /// - /// D1.2: a held ramp the drain starts is reported to the automated channel's cell, as the - /// fader bank's drain does. fn drain_controls(&mut self, snapshot: LiveSnapshot) -> Result<(), RenderError> { let fader = &mut self.fader; - let mut held = [false; 2]; let result = self .control .apply_latest(snapshot, |kind, lanes, words| { @@ -7631,11 +7599,6 @@ impl LiveControlFaderProcessor { fader.set_fader_db(lanes, f32::from_bits(words[0]), words[1]) } FaderKind::Mute => { - if words[1] != 0 { - for (mark, covered) in held.iter_mut().zip(selector_channels(lanes)) { - *mark |= covered; - } - } fader.set_mute(lanes, words[0] != 0, words[1]); Ok(()) } @@ -7643,13 +7606,6 @@ impl LiveControlFaderProcessor { }) .map_err(render_error); if let Some(automation) = self.automation.as_deref_mut() { - for (channel, _) in held.iter().enumerate().filter(|(_, held)| **held) { - if self.fader.gain_held(channel) == Some(true) - && let Some(remaining) = self.fader.ramp_remaining(channel) - { - automation.report_held_ramp(channel, remaining); - } - } // #1649 D3: the offsets cells, after the drain, as the fader bank reads them. automation.read_offsets(snapshot, &self.control.superseded, &mut self.fader); } diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index dd5f30e53..c7bfa4342 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -1149,7 +1149,7 @@ fn parity_model() -> SessionModel { } /// Render `rig` for `blocks` blocks; with handles, a live mute ramp of 300 samples starts on track -/// 1's left lane at block 10 and an unmute ramp at block 20, so a held ramp is reported (D1.2). +/// 1's left lane at block 10 and an unmute ramp at block 20, so the lane holds its gain. /// With handles, the offsets cells take two writes too (#1649 D3): track 0's two lanes a new `S` /// at block 15 (a jump of 480 samples), and track 1's left lane one at block 20, in the block /// whose drain starts the unmute ramp, so its jump meets a held gain. @@ -1942,6 +1942,43 @@ fn a_live_write_that_frees_a_held_lane_releases_its_hold() { } } +/// R17-10 with nothing owed (MAJOR-A of the U2 fold review): a live write that frees a lane the +/// stage held through the end of the previous block releases the cell's hold there, whether or not +/// a move is owed. The track is prepared muted on a curve flat at `-30` dB over `[0, 2_000]` (the +/// seeded value, so the cell gives no event inside the hold and owes nothing) and then fading to +/// `0` dB at 10,000; a live unmute of 4,000 samples is drained at 0, and a live unmute of window 0 +/// at 1,024 ends it there. From 1,024 the lane follows the curve: at each completion sample +/// `64k + 63` it equals the curve's gain exactly, at quantum 128 and 64 and on the oracle. +/// +/// Test value: red if the release is made only when a move is owed (the cell keeps a hold of the +/// ended ramp to 3,999, so the fade's grid events from 2,000 are only remembered on a free stage +/// and the lane stays at `-30` dB while the curve moves). +#[test] +fn a_live_write_that_frees_a_lane_with_nothing_owed_releases_its_hold() { + const FRAMES: u64 = 6_144; + let (model, entry) = muted_ride( + vec![ + segment(AutomationShape::Linear, 0, 2_000, -30.0, -30.0), + segment(AutomationShape::Linear, 2_000, 10_000, -30.0, 0.0), + ], + 10.0, + ); + let out = scripted_across(&model, &[128, 64], FRAMES, &|rig| match rig.rendered { + 0 => live_mute(rig, builtins::BuiltinLaneSelector::Both, false, 4_000), + 1_024 => live_mute(rig, builtins::BuiltinLaneSelector::Both, false, 0), + _ => {} + }); + for plane in &out { + for t in (1_024 + 63..FRAMES).step_by(64) { + assert_eq!( + plane[t as usize].to_bits(), + gain(curve(&entry, t as i64)).to_bits(), + "completion sample {t}" + ); + } + } +} + /// `model` with a true-peak limiter (5 ms lookahead) on track 0, and its fader's `a(n)`. fn latent(model: SessionModel) -> (SessionModel, u64) { let model = with_limiter(model, 0); From ae0c160cbf682907145b8c0e88a4d8b2dce4f75a Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 21:36:00 +0000 Subject: [PATCH 25/35] Fold the U3 review's non-render items (#1649, #1650b, TBD48) Gates that discriminate: TBD48's automated canonical_live rows use offsets whose S rounds under the hostile word (red without the command guard); a session booted under the hostile word is the canonical session (red without the compile_children guard; JSON parse is a TBD48 reason); wire field 35 is required on control rows and refused on headers; the in-place canonical bytes of a hand move equal the full path's (accounting was already right); the SDK control-list fixture carries an automated row; mask copies a ride's offset; the D6 order (hand offset first) is pinned; under_hostile_fp_env restores the caller's word. Docs: a static fader change on an automated lane is Structure. Co-Authored-By: Claude Opus 5.5 --- ...ca-offsets-with-stored-fader-automation.md | 7 + ...-automated-fader-or-trim-by-a-hand-move.md | 26 + ...ues-inside-the-canonical-fp-environment.md | 37 +- crates/capi/src/runtime/live_tests.rs | 218 ++++- crates/controls/tests/mask_and_diff.rs | 49 ++ crates/host-core/tests/live_delta.rs | 53 +- crates/lane/tests/fp_env.rs | 64 ++ crates/protocol/src/message_wire/tests.rs | 5 +- crates/protocol/src/model.rs | 62 ++ docs/CONTROL_PROTOCOL_SEMANTICS.md | 2 +- hosts/host-web/src/tests.rs | 82 +- hosts/host-web/tests/control_list_fixture.rs | 37 +- .../host-web/tests/fixtures/control-list.json | 804 +++++++++--------- sdk/test/control-list-evals.mjs | 8 + 14 files changed, 968 insertions(+), 486 deletions(-) diff --git a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md index bcdbb762a..479c86b5a 100644 --- a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md @@ -413,3 +413,10 @@ the predecessor's heard `S`, against the successor's curve composed with its see `offsets_jump`'s shape. **For #1650b.** A hand move on an automated fader changes `S` through `EffectiveStrip::offsets_sum`; `level_records_between` then writes the offsets cell with no other change (the `Fader` row length via `LevelRules::lengths`). + +## U3 fold (batch 17) + +- U3 MINOR 2: the #1649 row of both `canonical_live` modules (C ABI and browser) was vacuous (an + exact `f64` `S`). It now moves the VCA to `20` dB over a lane whose stored hand offset is + `-1e-30` dB, so `S` rounds differently under the hostile word and reaches another gain; red with + the `SessionState::command` guard removed. Details in the TBD48 spec's "U3 fold (batch 17)". diff --git a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md index d51bbf3bd..205e2dbdd 100644 --- a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md +++ b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md @@ -616,3 +616,29 @@ ok; fmt; clippy `-D warnings` (touched crates, all targets, all features); no-fe **Successor work.** (1) Index the rendered fader entries by strip once per list/catalog build instead of a scan per strip. (2) #1658 and #1654 extend `FaderOffsets`, `takes_offset` and the `automated` flag to the trim and the send gain. + +## U3 fold (batch 17) + +- **MINOR 5, in-place canonical bytes.** The accounting was right: `canonical_owner_bytes` counts + `OwnerRef::Automation` (`crates/session/src/in_place.rs`), and an in-place hand move of + `-3.123456` dB charges the same estimate as the full path. The reviewer's 3556 vs 3562 was the + mutant that counts the automation owner as 0 bytes. Gate: + `protocol` `model::tests::a_hand_move_on_an_automated_fader_charges_the_rides_canonical_bytes_in_place` + (in-place vs full `resource_estimate()`, snapshot and response; the text grows). Red under that + mutant (`canonical_bytes` 3555 vs 3561). +- **MINOR 4, wire field 35.** `message_wire::tests::control_entry_rules_refuse_on_encode_and_decode_alike` + now refuses a control row without field 35 (red when 35 decodes as optional, default false) and + a section header that carries it (red when 35 leaves the control-only list). +- **MINOR 6, SDK.** `hosts/host-web/tests/control_list_fixture.rs` rides the parity session's + submix `b` right fader (flat, hand offset `+1.5` dB) and writes each row's `automated`; the + fixture is reblessed (the `b` right `fader_db` row is the one automated row, value `+1.5`). + `sdk/test/control-list-evals.mjs` projects `automated` and asserts the fixture has that one row. + Red with the SDK decoding `automated` as `false`. +- **NIT 1, `mask_fader_offsets`.** Kept and tested: `mask_values` is public and its contract is + that two masked models differ only in what is not a control value; the offset is one. + `controls` `mask_and_diff::the_mask_copies_a_rides_hand_move_offset`; red with the call removed. +- **NIT 2, D6 order.** `live_delta::a_hand_move_on_an_automated_fader_writes_the_lanes_offsets_cell` + gains two VCAs (+24, -24 dB) and a hand move of `1e-10` dB, where the two orders give different + `S`; red with the hand offset added after the VCAs in `effective_strip`. +- **NIT 4.** `docs/CONTROL_PROTOCOL_SEMANTICS.md`: a static fader change of an automated lane is a + structure rebuild, not "no live record". diff --git a/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md b/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md index 23676de53..b544d1f89 100644 --- a/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md +++ b/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md @@ -69,8 +69,10 @@ Root ruling R17-7: inside batch 17, closes in this PR. - `SessionState::declare_discontinuity_serviced`: the one core path of `declare_discontinuity` and of a plain `session_seek` on both hosts; its successor's carried level values are composed under it. - - `compile_children` (`crates/control-plane/src/compile.rs`): the boot of both hosts (catalog - build and boot preparation). + - `compile_children` (`crates/control-plane/src/compile.rs`): the boot of both hosts (session + JSON parse, catalog build and boot preparation). The parse is environment-dependent on its + own: `str::parse::`'s fast path divides in `f32`, so `"0.1"` reads as `0x3dcccccd` + canonically and `0x3dcccccc` under round toward zero. - host-core `ControlCatalog::build` and `ControlCatalog::stage_live` (`crates/host-core/src/control_provider.rs`): the catalog's derived rows are composed in the canonical environment whatever the caller (`prepare_host_runtime*` builds the catalog outside @@ -133,3 +135,34 @@ Root ruling R17-7: inside batch 17, closes in this PR. panics included. The three gate tests call it with no `unsafe` of their own; capi, host-core and host-web name `lane` with `test-support` as a dev-dependency. The policy's allowlist is unchanged and passes. All three red checks were run again with the helper. + +## U3 fold (batch 17) + +- **Automated rows are not vacuous (U3 MINOR 2).** The #1649 and #1650b rows used the static + pair, whose `f64` `S` of two `f32` offsets is exact under any rounding mode, so they passed with + the guard removed. They now use their own constants: flat curve `3 * 2^-20` dB, VCA offset + `20` dB, hand offset `-1e-30` dB (below half an `f64` ulp of 20), prior hand offset `1` dB. + `S = (hand + 20) + 0` is `20` canonically and the `f64` below 20 under round toward zero; + `curve + 20` is an `f32` midpoint that rounds up (ties to even) while the lower `S` rounds down, + so the two `S` give different fader dB and gains (asserted: `assert_the_hostile_word_rounds_the_offsets_sum` + on the C ABI, the second vacuity assertion on the browser). The rows assert the rendered bits, + not the cell's `S` bits: no test accessor reads the offsets cell's value, and adding one is a + `builtins-compiler` edit outside this lane. Red, checked once per row with only the + `SessionState::command` guard removed (the static rows skipped): C ABI and browser, both + automated rows fail at the render-bits assertion. +- **Boot gate (U3 MINOR 3).** `runtime::live_tests::set_control::canonical_live::a_session_booted_under_a_hostile_caller_word_is_the_canonical_session` + (C ABI): a session with a fader and a VCA offset at `0.1` dB compiled and booted under the + hostile word has the canonical boot's snapshot and rendered bits; the parse of `"0.1"` differs + under that word (asserted). Red, checked once, with the `compile_children` guard removed (the + snapshot differs). +- **Helper restore (U3 NIT 5).** `lane` `tests/fp_env.rs` + `x86::under_hostile_fp_env_restores_the_callers_word` (and the AArch64 twin, compiled with + `cargo check --target aarch64-unknown-linux-gnu`): the helper installs + `hostile_fp_control_word(caller)` inside, and writes a non-canonical caller word back on return + and while unwinding. Red, checked once, with the helper's restore forgotten. +- **Nested guards (U3 NIT 5).** `declare_discontinuity_serviced`, `ControlCatalog::build` and + `stage_live` keep their own guards without a separate test each: every one runs inside, or + nests under, a guard the gates above prove (`SessionState::command`, `compile_children`, the + preparation entry), and a nested guard only saves and restores the canonical word. The browser + `canonical_live` rows cover the same core path as the C ABI rows; they stay as the browser's + entry check (`control_command` -> `run_command` -> `command`). diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 22de27007..af7ed9a86 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -8084,6 +8084,19 @@ mod set_control { const MEMBER_DB: f32 = -0.01384; const OFFSET_DB: f32 = 1.0 / 3.0; + /// The flat curve of the automated rows (#1649, #1650b): `3 * 2^-20` dB, exactly. + const CURVE_DB: f32 = 3.0 / 1_048_576.0; + /// The VCA offset of the automated rows. + const VCA_DB: f32 = 20.0; + /// The hand offset of the automated rows: below half an `f64` ulp of [`VCA_DB`], so the + /// lane's `S = (HAND_DB + VCA_DB) + 0` is `20` under round to nearest and the `f64` below + /// `20` under round toward zero. `CURVE_DB + 20` is a midpoint between two `f32` values + /// that rounds up (ties to even) and the composition with the lower `S` rounds down, so + /// the two `S` reach different fader dB and different gains (asserted below). + const HAND_DB: f32 = -1e-30; + /// The hand offset the hand-move row starts from, so that its move changes `S`. + const PRIOR_HAND_DB: f32 = 1.0; + /// [`vca_document`] with `eq0`'s left fader at `member` and `inner`'s left offset at /// `offset` (`outer` stays at `0` dB, so the offsets' sum is `offset` exactly). fn document(member: f32, offset: f32) -> String { @@ -8093,10 +8106,11 @@ mod set_control { }) } - /// [`document`] with `eq0`'s left fader also automated, flat at `member` (#1649): its - /// gain is composed at render from the curve and the offsets cell's `S`. - fn automated_document(member: f32, offset: f32) -> String { - with_model(&document(member, offset), |model| { + /// [`document`] with `eq0`'s left fader at [`CURVE_DB`] and also automated, flat at + /// [`CURVE_DB`] with the stored hand offset `hand` (#1649, #1650): its gain is composed at + /// render from the curve and the offsets cell's `S = (hand + vca) + 0`. + fn automated_document(hand: f32, vca: f32) -> String { + with_model(&document(CURVE_DB, vca), |model| { model.automation.push(session::Automation { id: id("eq0-flat"), target: session::ControlAddress { @@ -8109,15 +8123,23 @@ mod set_control { shape: session::AutomationShape::Step, start_sample: 0, end_sample: SOURCE_FRAMES, - start_value: member, - end_value: member, + start_value: CURVE_DB, + end_value: CURVE_DB, unit: session::ParameterUnit::Db, }], - offset: session::AutomationOffset::default(), + offset: session::AutomationOffset { + left_db: hand, + right_db: 0.0, + }, }); }) } + /// The gain bits of `db`. + fn gain(db: f32) -> Option { + builtins::checked_fader_gain(db).map(f32::to_bits).ok() + } + /// The fixture is not vacuous: the hostile word composes the pair to another `f32`, and /// that `f32` converts to another gain. fn assert_the_hostile_word_rounds_the_composition() { @@ -8130,17 +8152,50 @@ mod set_control { "the gate is vacuous: the composition rounds alike under the hostile word" ); assert_ne!( - builtins::checked_fader_gain(canonical).map(f32::to_bits), - builtins::checked_fader_gain(rounded).map(f32::to_bits), + gain(canonical), + gain(rounded), "the gate is vacuous: the composition's rounding does not reach the gain" ); } + /// The automated rows are not vacuous: the hostile word sums the lane's `S` to another + /// `f64`, and render's composition of the curve with it (canonical, on the render + /// thread) reaches another fader dB and another gain. + fn assert_the_hostile_word_rounds_the_offsets_sum() { + let sum = || session::vca_offsets_sum([HAND_DB, VCA_DB, 0.0]).expect("three offsets"); + let canonical = sum(); + let rounded = under_hostile_fp_env(sum); + assert_eq!(canonical.to_bits(), f64::from(VCA_DB).to_bits()); + assert_ne!( + canonical.to_bits(), + rounded.to_bits(), + "the gate is vacuous: S sums alike under the hostile word" + ); + let (canonical, rounded) = ( + session::vca_compose_db(CURVE_DB, canonical), + session::vca_compose_db(CURVE_DB, rounded), + ); + assert_ne!( + canonical.to_bits(), + rounded.to_bits(), + "the gate is vacuous: the two S compose to one fader dB" + ); + assert_ne!( + gain(canonical), + gain(rounded), + "the gate is vacuous: the two fader dB give one gain" + ); + } + /// One case: a rig on `before` takes `edit` under the hostile word, live, and from the /// block its ramp leaves behind renders the bits of a canonical preparation of the - /// committed session; the catalog's derived `effective_fader_db` row holds the canonical - /// composition. - fn live_move_under_hostile_word(label: &str, before: &str, edit: SessionEdit) { + /// committed session. `committed` checks the committed model and the rig's catalog. + fn live_move_under_hostile_word( + label: &str, + before: &str, + edit: SessionEdit, + committed: impl FnOnce(&mut Rig, &SessionModel), + ) { let mut rig = Rig::new(before); for _ in 0..4 { rig.step(); @@ -8159,32 +8214,14 @@ mod set_control { (revision + 1, epoch, pending), "{label}: one revision, the same plan, no candidate" ); - let committed = rig.snapshot(); - let model = parse_session_json(&committed).expect("model"); - assert_eq!( - ( - model.tracks[0].fader.left_db.to_bits(), - model.vcas[0].fader.left_db.to_bits() - ), - (MEMBER_DB.to_bits(), OFFSET_DB.to_bits()), - "{label}: the committed pair is the fixture's" - ); - let (list, ..) = control_list_all_c(rig.session, &mut rig.request, 256); - let derived = listed(&list, |entry, row| { - entry.address.owner_id.as_deref() == Some("eq0") - && row.name == "effective_fader_db" - && entry.address.lane == Some(protocol::ParameterChannel::Left) - }); - assert_eq!( - control_of(derived).map(|row| row.value.to_bits()), - Some(session::vca_effective_db(MEMBER_DB, [OFFSET_DB, 0.0]).to_bits()), - "{label}: the catalog's derived row holds the canonical composition" - ); + let snapshot = rig.snapshot(); + let model = parse_session_json(&snapshot).expect("model"); + committed(&mut rig, &model); let ramps = model.control_smoothing_samples(); let k = window(rig.latency(), ramps.fader.max(64), rig.quantum); let edit_block = rig.block; let live: Vec> = (0..k + 4).map(|_| rig.step()).collect(); - let reference = Reference::run(&committed, rig.block); + let reference = Reference::run(&snapshot, rig.block); for (offset, pcm) in live.iter().enumerate().skip(k as usize) { let block = edit_block as usize + offset; assert!(pcm.iter().any(|sample| *sample != 0.0), "{label}: signal"); @@ -8196,44 +8233,131 @@ mod set_control { } } + /// The static rows' check: the committed pair is the fixture's, and the catalog's derived + /// `effective_fader_db` row holds the canonical composition. + fn static_pair(label: &'static str) -> impl FnOnce(&mut Rig, &SessionModel) { + move |rig, model| { + assert_eq!( + ( + model.tracks[0].fader.left_db.to_bits(), + model.vcas[0].fader.left_db.to_bits() + ), + (MEMBER_DB.to_bits(), OFFSET_DB.to_bits()), + "{label}: the committed pair is the fixture's" + ); + let (list, ..) = control_list_all_c(rig.session, &mut rig.request, 256); + let derived = listed(&list, |entry, row| { + entry.address.owner_id.as_deref() == Some("eq0") + && row.name == "effective_fader_db" + && entry.address.lane == Some(protocol::ParameterChannel::Left) + }); + assert_eq!( + control_of(derived).map(|row| row.value.to_bits()), + Some(session::vca_effective_db(MEMBER_DB, [OFFSET_DB, 0.0]).to_bits()), + "{label}: the catalog's derived row holds the canonical composition" + ); + } + } + + /// The automated rows' check: the committed hand offset and VCA offset are the fixture's. + fn automated_pair(label: &'static str) -> impl FnOnce(&mut Rig, &SessionModel) { + move |_, model| { + let ride = model + .automation + .iter() + .find(|entry| entry.id == id("eq0-flat")) + .expect("the ride"); + assert_eq!( + ( + ride.offset.left_db.to_bits(), + model.vcas[0].fader.left_db.to_bits() + ), + (HAND_DB.to_bits(), VCA_DB.to_bits()), + "{label}: the committed pair is the fixture's" + ); + } + } + /// TBD48 D2 (C ABI): a live fader move on a VCA member, a live VCA offset move over it, - /// (#1649) that VCA move over a member whose fader is automated, which writes its - /// offsets cell, and (#1650b) a hand move on that automated fader, which writes it too, - /// each made while the caller's thread runs FTZ, DAZ and round toward zero, render - /// from the end of their ramp the bits of the same committed session prepared on a - /// canonical thread, and the catalog's derived fader row holds the canonical composition. - /// The pair's composition rounds to another `f32`, and another gain, under that word - /// (asserted), so the gate is not vacuous. + /// (#1649) a VCA move over a member whose fader is automated, which writes its offsets + /// cell, and (#1650b) a hand move on that automated fader, which writes it too, each made + /// while the caller's thread runs FTZ, DAZ and round toward zero, render from the end of + /// their ramp the bits of the same committed session prepared on a canonical thread, and + /// the catalog's derived fader row holds the canonical composition. The static pair's + /// composition rounds to another `f32`, and another gain, under that word, and the + /// automated rows' `S` sums to another `f64`, which render composes to another gain + /// (both asserted), so no row is vacuous. /// /// Test value: red if a live transaction composes a member's effective fader (the VCA - /// sum) in the caller's environment: remove the guard in `SessionState::command`. + /// sum) or an automated lane's offsets-cell `S` in the caller's environment: remove the + /// guard in `SessionState::command`. #[test] fn a_live_fader_and_vca_move_under_a_hostile_caller_word_render_the_canonical_bits() { assert_the_hostile_word_rounds_the_composition(); + assert_the_hostile_word_rounds_the_offsets_sum(); live_move_under_hostile_word( "a live fader move", &document(0.0, OFFSET_DB), set(builtin("eq0", FADER_DB, Lane::Left), MEMBER_DB, None), + static_pair("a live fader move"), ); live_move_under_hostile_word( "a live VCA move", &document(MEMBER_DB, 0.0), set(vca_row("inner", VCA_OFFSET_DB, Lane::Left), OFFSET_DB, None), + static_pair("a live VCA move"), ); - // #1649: the same VCA move over an automated member writes its offsets cell. + // #1649: a VCA move over an automated member writes its offsets cell. live_move_under_hostile_word( "a live VCA move on an automated lane", - &automated_document(MEMBER_DB, 0.0), - set(vca_row("inner", VCA_OFFSET_DB, Lane::Left), OFFSET_DB, None), + &automated_document(HAND_DB, 0.0), + set(vca_row("inner", VCA_OFFSET_DB, Lane::Left), VCA_DB, None), + automated_pair("a live VCA move on an automated lane"), ); // #1650b: a hand move on the automated lane writes its offsets cell with `S` = the // stored offset and the VCA's. live_move_under_hostile_word( "a hand move on an automated lane", - &automated_document(MEMBER_DB, OFFSET_DB), - set(builtin("eq0", FADER_DB, Lane::Left), MEMBER_DB, None), + &automated_document(PRIOR_HAND_DB, VCA_DB), + set(builtin("eq0", FADER_DB, Lane::Left), HAND_DB, None), + automated_pair("a hand move on an automated lane"), ); } + + /// TBD48 D1 (C ABI): a session compiled and booted from its JSON while the caller's + /// thread runs FTZ, DAZ and round toward zero is the session a canonical boot gives: the + /// same committed snapshot and the same rendered bits. The session's JSON parse reads + /// `0.1` as another `f32` under that word (asserted), so the gate is not vacuous. + /// + /// Test value: red if `compile_children` parses or prepares the session in the caller's + /// environment: remove its `CanonicalFpEnv` guard. + #[test] + fn a_session_booted_under_a_hostile_caller_word_is_the_canonical_session() { + let parse = || "0.1".parse::().expect("a number"); + assert_ne!( + parse().to_bits(), + under_hostile_fp_env(parse).to_bits(), + "the gate is vacuous: the parse rounds alike under the hostile word" + ); + let document = with_model(&vca_document(), |model| { + model.tracks[0].fader.left_db = 0.1; + model.vcas[0].fader.left_db = 0.1; + }); + let mut canonical = Rig::new(&document); + let mut hostile = under_hostile_fp_env(|| Rig::new(&document)); + assert_eq!( + hostile.snapshot(), + canonical.snapshot(), + "the committed session is the canonical parse" + ); + let mut signal = false; + for block in 0..16 { + let (pcm, reference) = (hostile.step(), canonical.step()); + signal |= pcm.iter().any(|sample| *sample != 0.0); + assert_eq!(bits(&pcm), bits(&reference), "block {block}"); + } + assert!(signal, "signal"); + } } /// #1649: a VCA edit over a member with stored fader automation, on the C ABI. diff --git a/crates/controls/tests/mask_and_diff.rs b/crates/controls/tests/mask_and_diff.rs index be19ee5fd..d9a71f380 100644 --- a/crates/controls/tests/mask_and_diff.rs +++ b/crates/controls/tests/mask_and_diff.rs @@ -94,6 +94,55 @@ fn the_mask_copies_and_the_diff_sees_every_rows_field() { } } +/// #1650 D5 (U3 fold, batch 17). A hand move on an automated fader writes the ride's offset, the +/// value of the lane's `fader_db` row; the mask copies it back from the other model, and keeps the +/// lane's static fader (its fallback) as the masked model holds it. +/// +/// Test value: red if the mask leaves a ride's hand-move offset out (the masked model keeps the +/// moved offset), or if it copies the static fader of an automated lane. +#[test] +fn the_mask_copies_a_rides_hand_move_offset() { + let mut base = effects_fixture(); + let strip = base.tracks[0].id.clone(); + base.automation.push(session::Automation { + id: id("fader-ride"), + target: common::address( + Owner::Strip(strip.clone()), + Section::Builtins, + 5, + Lane::Both, + ), + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: 0, + end_sample: 480, + start_value: -6.0, + end_value: -6.0, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + }); + let ride = base.automation.len() - 1; + for lane in [Lane::Left, Lane::Right] { + let fader = common::address(Owner::Strip(strip.clone()), Section::Builtins, 5, lane); + let mut edited = base.clone(); + set(&mut edited, &fader, -3.5, None, RATE).expect("a hand move"); + assert_ne!( + edited.automation[ride].offset, base.automation[ride].offset, + "{lane:?}: the move writes the offset" + ); + let mut masked = edited.clone(); + mask_values(&mut masked, &base); + assert_eq!(masked, base, "{lane:?}: the mask copies the offset"); + } + // The static fader of an automated lane is not a control value: the mask keeps it. + let mut fallback = base.clone(); + fallback.tracks[0].fader.left_db = -9.0; + let mut masked = fallback.clone(); + mask_values(&mut masked, &base); + assert_eq!(masked, fallback, "the fallback stays different"); +} + /// Red if the mask copies a field that is not a control value, or hides an owner it does not /// share: a removed strip, a renamed strip and a reordered insert stay different after the mask, /// and the diff of two models with different owners has no answer. diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 866a8b2f2..c242aa96d 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -1721,7 +1721,8 @@ fn a_vca_move_writes_the_automated_lanes_offsets_cell() { /// give the full classifier's delta. /// /// Test value: red if the hand move lands on the static fader (`model_only` or a `FaderDb` the -/// curve overrides), if `S` leaves out the hand offset or adds it after the VCA, if the move takes +/// curve overrides), if `S` leaves out the hand offset or adds it after the VCAs (two VCAs and a +/// hand offset below half an ulp of their sum's first term), if the move takes /// the VCA row's length, if a static fallback change commits live without being heard, or if the /// in-place view reads the lane's committed offset instead of the edited one. #[test] @@ -1795,6 +1796,56 @@ fn a_hand_move_on_an_automated_fader_writes_the_lanes_offsets_cell() { .map(|delta| level(&delta)), Err(LiveRebuild::Structure) ); + // D6 order (U3 fold, batch 17): two VCAs at +24 and -24 dB reach the track, and a hand move of + // 1e-10 dB is below half an ulp of 24 in `f64`. `S` adds the hand offset first, then the VCAs + // in ascending ID order: `(1e-10 + 24) + -24`, which keeps only the rounded part of 1e-10; the + // order that adds the hand offset last gives `1e-10` itself. + let two_vcas = normalized(&edited(¤t, |model| { + let track = model.tracks[0].id.clone(); + model.vcas = vec![ + Vca { + id: id("vca-a"), + fader: DualMonoFader { + left_db: 24.0, + right_db: 24.0, + left_mute: false, + right_mute: false, + }, + members: vec![track.clone()], + }, + Vca { + id: id("vca-b"), + fader: DualMonoFader { + left_db: -24.0, + right_db: -24.0, + left_mute: false, + right_mute: false, + }, + members: vec![track], + }, + ]; + })); + let tiny = 1e-10_f32; + let ordered = (f64::from(tiny) + 24.0) + -24.0; + assert_ne!( + ordered.to_bits(), + f64::from(tiny).to_bits(), + "the two orders differ" + ); + assert_eq!( + classify_live_delta( + &two_vcas, + &edited(&two_vcas, |next| { + controls::set(next, &fader(ParameterChannel::Left), tiny, None, rate) + .expect("a hand move"); + }), + session_ramps, + &EditRamps::new(), + ) + .map(|delta| level(&delta)), + Ok(vec![(first.clone(), Vec::new(), [sum(ordered, 100), None])]), + "the hand offset first, then the VCAs" + ); #[cfg(feature = "test-support")] { diff --git a/crates/lane/tests/fp_env.rs b/crates/lane/tests/fp_env.rs index 4539d73bb..d39ab2a82 100644 --- a/crates/lane/tests/fp_env.rs +++ b/crates/lane/tests/fp_env.rs @@ -107,6 +107,38 @@ mod x86 { ); } + /// TBD48 (U3 fold, batch 17): the test helper `under_hostile_fp_env` installs the hostile + /// word inside its body and writes the caller's exact word back after it, also when the body + /// panics. The caller's word is itself non-canonical (round up, a sticky flag), so a helper + /// that wrote the canonical word back would be seen. + /// + /// Test value: red if the helper leaves the hostile word on the caller's thread (on return or + /// while unwinding), restores another word than the caller's, or does not install the hostile + /// word, so every TBD48 gate built on it would run in the wrong environment. + #[cfg(feature = "test-support")] + #[test] + fn under_hostile_fp_env_restores_the_callers_word() { + let saved = read_mxcsr(); + let _restore = Restore(saved); + let caller = hostile_word(saved); + // SAFETY: `hostile_word` sets FTZ, DAZ, RC and a status flag on a word read from this + // thread, all below bit 16; `_restore` writes `saved` back. + unsafe { write_mxcsr(caller) }; + let inside = fpenv::under_hostile_fp_env(read_mxcsr); + assert_eq!(inside, fpenv::hostile_fp_control_word(caller)); + assert_ne!(inside, caller, "the helper changes the word"); + assert_eq!(read_mxcsr(), caller, "the caller's word comes back"); + let outcome = std::panic::catch_unwind(|| { + fpenv::under_hostile_fp_env(|| panic!("a body that panics")); + }); + assert!(outcome.is_err(), "the body panicked"); + assert_eq!( + read_mxcsr(), + caller, + "the caller's word comes back while unwinding" + ); + } + #[test] fn the_caller_word_survives_an_unwind_through_the_guard() { let saved = read_mxcsr(); @@ -261,6 +293,38 @@ mod aarch64 { (base & !(0b11 << 22)) | FPCR_FZ | FPCR_DN | FPCR_RMODE_UP } + /// TBD48 (U3 fold, batch 17): as the `x86_64` test of the same name. + /// + /// Test value: red if the helper leaves the hostile word on the caller's thread (on return or + /// while unwinding), restores another word than the caller's, or does not install it. + #[cfg(feature = "test-support")] + #[test] + fn under_hostile_fp_env_restores_the_callers_word() { + let saved = read_fp_control_word(); + let _restore = Restore(saved); + let caller = (saved & !(0b11 << 22)) | FPCR_RMODE_UP; + // SAFETY: `caller` changes only `RMode` of a word read from this thread; `_restore` + // writes `saved` back. + unsafe { write_fp_control_word(caller) }; + let inside = fpenv::under_hostile_fp_env(read_fp_control_word); + assert_eq!(inside, fpenv::hostile_fp_control_word(caller)); + assert_ne!(inside, caller, "the helper changes the word"); + assert_eq!( + read_fp_control_word(), + caller, + "the caller's word comes back" + ); + let outcome = std::panic::catch_unwind(|| { + fpenv::under_hostile_fp_env(|| panic!("a body that panics")); + }); + assert!(outcome.is_err(), "the body panicked"); + assert_eq!( + read_fp_control_word(), + caller, + "the caller's word comes back while unwinding" + ); + } + #[test] fn a_hostile_caller_word_is_normalised_and_returned_bit_exactly() { let saved = read_fp_control_word(); diff --git a/crates/protocol/src/message_wire/tests.rs b/crates/protocol/src/message_wire/tests.rs index e7ded93b8..25151352e 100644 --- a/crates/protocol/src/message_wire/tests.rs +++ b/crates/protocol/src/message_wire/tests.rs @@ -1325,6 +1325,9 @@ fn control_entry_rules_refuse_on_encode_and_decode_alike() { ("owner header with a value", None, |f| { insert(entry(f, 0), leaf(34, 6, &f32_bytes(1.0))) }), + ("section header with an automated flag", None, |f| { + insert(entry(f, 2), leaf(35, 8, &[0])) + }), ("section header with a name of a control", None, |f| { insert(entry(f, 3), leaf(10, 9, b"ratio")) }), @@ -1465,7 +1468,7 @@ fn control_entry_rules_refuse_on_encode_and_decode_alike() { // A control row that lacks any one of its required fields is refused. for id in [ - 10, 11, 12, 13, 14, 15, 16, 18, 19, 20, 21, 22, 25, 26, 30, 31, 32, 33, 34, + 10, 11, 12, 13, 14, 15, 16, 18, 19, 20, 21, 22, 25, 26, 30, 31, 32, 33, 34, 35, ] { let (patched, count) = edited(&bytes, |f| remove(entry(f, 4), id)); assert_eq!( diff --git a/crates/protocol/src/model.rs b/crates/protocol/src/model.rs index 562ccb31f..81926c033 100644 --- a/crates/protocol/src/model.rs +++ b/crates/protocol/src/model.rs @@ -1885,6 +1885,68 @@ mod tests { ); } + /// #1650 (U3 fold, batch 17). A hand move on an automated fader is prepared in place with the + /// ride as its edited owner, and the in-place commit charges the ride's canonical bytes as the + /// full path does: the offset `-3.123456` is spelled longer than `0.0`, so the canonical text + /// grows. + /// + /// Test value: red if the in-place accounting leaves the automation owner out of the canonical + /// bytes (it would under-charge `canonical_bytes` by the offset's growth), or if the hand move is + /// not prepared in place. + #[test] + fn a_hand_move_on_an_automated_fader_charges_the_rides_canonical_bytes_in_place() { + let mut model = parse_session_json(EXAMPLE).expect("fixture"); + let mut ride = model.automation[0].clone(); + ride.id = id("fader-ride"); + ride.target = row_address( + Owner::Strip(model.tracks[0].id.clone()), + Section::Builtins, + "fader_db", + Lane::Both, + ) + .expect("the fader row"); + model.automation.push(ride); + let mut fast = SessionStore::new(model.clone(), caps()).expect("compile"); + let mut full = SessionStore::new(model, caps()).expect("compile"); + let track = fast.compiled().normalized_model().tracks[0].id.clone(); + let hand_move = SessionEdit::set_control( + row_address( + Owner::Strip(track), + Section::Builtins, + "fader_db", + Lane::Left, + ) + .expect("the fader row"), + -3.123_456, + None, + ); + let edits = [hand_move]; + let revision = ExpectedRevision::Exact(fast.revision()); + let prepared = fast + .prepare_transaction_in_place(revision, &edits) + .expect("prepares"); + assert!(prepared.is_in_place()); + assert_eq!( + prepared.edited_owners().expect("owners").automation.len(), + 1, + "the ride is the edited owner" + ); + let before = fast.compiled().resource_estimate().canonical_bytes; + assert_eq!( + fast.apply_transaction_in_place(revision, &edits), + full.apply_transaction_with_monitor(revision, &edits) + ); + assert_eq!(fast.canonical_snapshot(), full.canonical_snapshot()); + assert_eq!( + fast.compiled().resource_estimate(), + full.compiled().resource_estimate() + ); + assert!( + fast.compiled().resource_estimate().canonical_bytes > before, + "the offset's spelling grows the canonical text" + ); + } + /// #1683 review fold: a solo and a master write of one strip in one transaction are two monitor /// writes, on the in-place path and on the full path alike. The rule that a later write /// replaces an earlier one holds per strip and row, never per strip. diff --git a/docs/CONTROL_PROTOCOL_SEMANTICS.md b/docs/CONTROL_PROTOCOL_SEMANTICS.md index b24f4506a..51bd62493 100644 --- a/docs/CONTROL_PROTOCOL_SEMANTICS.md +++ b/docs/CONTROL_PROTOCOL_SEMANTICS.md @@ -18,7 +18,7 @@ All v1 operations are absolute and idempotent. A new logical command has a nonze There is one way to make a change at a sample time: a stored automation edit (`SessionEdit` opcodes `0600`-`0603`), which the engine renders from the session's stored automation table (#1058). The command `AUTOMATION_ENQUEUE` (`0006`), which once admitted a batch of timed records to a queue, is **retired** (#1627, under the owner ruling in `rulings/engine-footprint-2026-09-28.md`: sample-timed delivery had no product consumer, and a host that admitted a batch acked work that no PCM reached). A frame under message ID `0006` is refused at decode as `UNSUPPORTED_MESSAGE`, before the revision check, so no revision, event, queue or replay entry changes and no ack can precede a drop; no endpoint lists `0006`, flag bit 5 or the capability fields that described its admission (9, 12, 19 and 20), and statuses 15 (`TIME_IN_PAST`) and 16 (`AUTOMATION_ORDER`) are retired with it. Every retired code is refused, never reallocated and never renumbered (see the [registry](CONTROL_PROTOCOL_REGISTRY.md)). The `Step`, `Linear` and `Exponential` span kinds and the `Sample` automation rate are retired with it (#1636, `EFFECT_CONTRACT_V1.md`, "V1 runtime automation"). -**Delivery history (v1).** The delivery of the command was planned by #140, descoped by the owner ruling in `rulings/engine-footprint-2026-09-28.md`, and its unwired partial implementation (the #460 delivery service, its controller facade and the host-core builtin-batch and scalar-point endpoints) was deleted by #1056. #1315 then refused the command `UNAVAILABLE` on both hosts behind a provider feature switch that nothing set; #1627 retired the command and removed the switch. Two outcomes have their own owners instead. Rendering the session's stored automation table is #1058. Live value edits on the C ABI are delivered by #1053: a committed `SESSION_TRANSACTION_APPLY` whose delta is only live values is lowered onto the running plan's live-control lanes, with the same response and events as before. The live values are a track's fader, mute and pan or matrix values (#1257); a changed value of a track effect parameter whose `automation_rate` is `Block`, on a console-slot entry or an insert (#1264), the parametric EQ's live parameters included, which ride prepared targets (#1265); and a track effect's bypass, except the delay's and the multiband compressor's, which rebuild (#1266). A transaction that changes only the session ID, a profile's id or a stored automation entry on a row that does not render yet commits with no replacement and no live value (#1260). A stored fader automation edit (an entry on a strip's `fader_db` added, removed, retargeted or given other segments) is a rebuild that carries every owner (#1648); a static fader value of a lane the session automates is that lane's fallback and gives no live record (#1648 D3), and a `SET_CONTROL fader_db` on that lane writes the entry's hand offset, a live value heard on top of the curve (#1650); every other row stays model-only until its rendering slice adds it. Every other transaction still replaces the plan, including every submix strip value (#1053 G1), any edit while a VCA is declared (G3) and a mute change on a track that a `follows_mute` route reads (G2) (`C_ABI_V1_QUALIFICATION.md`, "Value-only track edits on the running plan" and the sections after it). +**Delivery history (v1).** The delivery of the command was planned by #140, descoped by the owner ruling in `rulings/engine-footprint-2026-09-28.md`, and its unwired partial implementation (the #460 delivery service, its controller facade and the host-core builtin-batch and scalar-point endpoints) was deleted by #1056. #1315 then refused the command `UNAVAILABLE` on both hosts behind a provider feature switch that nothing set; #1627 retired the command and removed the switch. Two outcomes have their own owners instead. Rendering the session's stored automation table is #1058. Live value edits on the C ABI are delivered by #1053: a committed `SESSION_TRANSACTION_APPLY` whose delta is only live values is lowered onto the running plan's live-control lanes, with the same response and events as before. The live values are a track's fader, mute and pan or matrix values (#1257); a changed value of a track effect parameter whose `automation_rate` is `Block`, on a console-slot entry or an insert (#1264), the parametric EQ's live parameters included, which ride prepared targets (#1265); and a track effect's bypass, except the delay's and the multiband compressor's, which rebuild (#1266). A transaction that changes only the session ID, a profile's id or a stored automation entry on a row that does not render yet commits with no replacement and no live value (#1260). A stored fader automation edit (an entry on a strip's `fader_db` added, removed, retargeted or given other segments) is a rebuild that carries every owner (#1648); a static fader value of a lane the session automates is that lane's fallback, not a control value, so a change of it is a structure rebuild (#1650b; #1648 D3), and a `SET_CONTROL fader_db` on that lane writes the entry's hand offset, a live value heard on top of the curve (#1650); every other row stays model-only until its rendering slice adds it. Every other transaction still replaces the plan, including every submix strip value (#1053 G1), any edit while a VCA is declared (G3) and a mute change on a track that a `follows_mute` route reads (G2) (`C_ABI_V1_QUALIFICATION.md`, "Value-only track edits on the running plan" and the sections after it). **Sources across a structural transaction.** A committed transaction that replaces the plan does not reset the sources it leaves unchanged. On the C ABI (#1273) such a source keeps its ring, generation and read position across the plan swap; source submissions and seeks address the newest committed session from the commit on, so a host keeps feeding the source contiguously and must not reseek it. PCM is never part of the protocol, so the transaction acknowledges no PCM: PCM already accepted for a source the transaction removed is discarded with its plan, and later submissions for it are refused as `source.id.unknown`. An added source starts at generation 1, frame 0, until the host seeks it; a source whose declaration the transaction changed restarts the same way, in a new ring at generation 1, frame 0, with the PCM accepted for it before the commit discarded with its plan, so the host restarts its feed. diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index ce09c8285..9ba2fd3c1 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -10249,6 +10249,14 @@ mod canonical_live { const OFFSET_DB: f32 = 1.0 / 3.0; /// The track the VCA holds. const MEMBER: usize = 1; + /// The automated rows' flat curve, VCA offset, hand offset and prior hand offset: the C ABI + /// `canonical_live` module's, where the choice is explained. `S = HAND_DB + VCA_DB` sums to + /// `20` under round to nearest and to the `f64` below it under round toward zero, and render's + /// composition of the curve with the two reaches different gains (asserted below). + const CURVE_DB: f32 = 3.0 / 1_048_576.0; + const VCA_DB: f32 = 20.0; + const HAND_DB: f32 = -1e-30; + const PRIOR_HAND_DB: f32 = 1.0; /// [`flat_fader_base`] with VCA `v` over track 1: the track's left fader at `member`, the /// VCA's left offset at `offset`. @@ -10377,14 +10385,17 @@ mod canonical_live { } /// TBD48 D2 (browser): a live fader move on a VCA member, a live VCA offset move over it, - /// (#1649) that VCA move over a member whose fader is automated, and (#1650b) a hand move on - /// that automated fader, each made through the browser's control entry while the caller's thread runs FTZ, DAZ and - /// round toward zero, render from the end of their ramp the bits of the committed model booted - /// on a canonical thread. The pair's composition rounds to another `f32`, and another gain, - /// under that word (asserted), so the gate is not vacuous. + /// (#1649) a VCA move over a member whose fader is automated, and (#1650b) a hand move on + /// that automated fader, each made through the browser's control entry while the caller's + /// thread runs FTZ, DAZ and round toward zero, render from the end of their ramp the bits of + /// the committed model booted on a canonical thread. The static pair's composition rounds to + /// another `f32`, and another gain, under that word, and the automated rows' `S` sums to + /// another `f64`, which render composes to another gain (both asserted), so no row is + /// vacuous. /// - /// Test value: red if the browser's live transaction composes a member's effective fader in - /// the caller's environment: remove the guard in the core's `SessionState::command`. + /// Test value: red if the browser's live transaction composes a member's effective fader, or + /// an automated lane's offsets-cell `S`, in the caller's environment: remove the guard in the + /// core's `SessionState::command`. #[test] fn a_live_fader_and_vca_move_under_a_hostile_caller_word_render_the_canonical_bits() { let compose = || session::vca_effective_db(MEMBER_DB, [OFFSET_DB]); @@ -10394,13 +10405,24 @@ mod canonical_live { builtins::checked_fader_gain(rounded).map(f32::to_bits), "the gate is vacuous: the hostile word's composition converts to the same gain" ); - let member = model(0.0, OFFSET_DB).tracks[MEMBER].id.clone(); - let vca_move = set( - Owner::Vca(StableId::parse("v").expect("VCA ID")), - Section::Own, - 1, - OFFSET_DB, + let sum = || session::vca_offsets_sum([HAND_DB, VCA_DB]).expect("two offsets"); + let rounded = under_hostile_fp_env(sum); + assert_ne!( + builtins::checked_fader_gain(session::vca_compose_db(CURVE_DB, sum())) + .map(f32::to_bits), + builtins::checked_fader_gain(session::vca_compose_db(CURVE_DB, rounded)) + .map(f32::to_bits), + "the gate is vacuous: the hostile word's S converts to the same gain" ); + let member = model(0.0, OFFSET_DB).tracks[MEMBER].id.clone(); + let vca_move = |offset| { + set( + Owner::Vca(StableId::parse("v").expect("VCA ID")), + Section::Own, + 1, + offset, + ) + }; live_move_under_hostile_word( "a live fader move", &model(0.0, OFFSET_DB), @@ -10410,36 +10432,35 @@ mod canonical_live { live_move_under_hostile_word( "a live VCA move", &model(MEMBER_DB, 0.0), - &vca_move, + &vca_move(OFFSET_DB), &model(MEMBER_DB, OFFSET_DB), ); - // #1649: the same VCA move over a member whose left fader is automated, flat at the - // member's dB, writes the lane's offsets cell; render composes the curve with it. + // #1649: a VCA move over a member whose left fader is automated, flat at the curve's dB, + // writes the lane's offsets cell; render composes the curve with it. live_move_under_hostile_word( "a live VCA move on an automated lane", - &automated(model(MEMBER_DB, 0.0)), - &vca_move, - &automated(model(MEMBER_DB, OFFSET_DB)), + &automated(model(CURVE_DB, 0.0), HAND_DB), + &vca_move(VCA_DB), + &automated(model(CURVE_DB, VCA_DB), HAND_DB), ); // #1650b: a hand move on that automated fader writes the ride's left offset, and the // lane's offsets cell with `S` = the offset and the VCA's; the static fader stays. - let mut moved = automated(model(MEMBER_DB, OFFSET_DB)); - moved.automation[0].offset.left_db = MEMBER_DB; live_move_under_hostile_word( "a hand move on an automated lane", - &automated(model(MEMBER_DB, OFFSET_DB)), + &automated(model(CURVE_DB, VCA_DB), PRIOR_HAND_DB), &set( Owner::Strip(model(0.0, 0.0).tracks[MEMBER].id.clone()), Section::Builtins, 5, - MEMBER_DB, + HAND_DB, ), - &moved, + &automated(model(CURVE_DB, VCA_DB), HAND_DB), ); } - /// `model` with track [`MEMBER`]'s left fader automated, flat at [`MEMBER_DB`] (#1649). - fn automated(mut model: session::SessionModel) -> session::SessionModel { + /// `model` with track [`MEMBER`]'s left fader automated, flat at [`CURVE_DB`], with the stored + /// left hand offset `hand` (#1649, #1650). + fn automated(mut model: session::SessionModel, hand: f32) -> session::SessionModel { let owner = session::Owner::Strip(model.tracks[MEMBER].id.clone()); model.automation.push(session::Automation { id: StableId::parse("member-flat").expect("automation ID"), @@ -10453,11 +10474,14 @@ mod canonical_live { shape: session::AutomationShape::Step, start_sample: 0, end_sample: 1 << 20, - start_value: MEMBER_DB, - end_value: MEMBER_DB, + start_value: CURVE_DB, + end_value: CURVE_DB, unit: session::ParameterUnit::Db, }], - offset: session::AutomationOffset::default(), + offset: session::AutomationOffset { + left_db: hand, + right_db: 0.0, + }, }); model } diff --git a/hosts/host-web/tests/control_list_fixture.rs b/hosts/host-web/tests/control_list_fixture.rs index 428d7491e..419307e58 100644 --- a/hosts/host-web/tests/control_list_fixture.rs +++ b/hosts/host-web/tests/control_list_fixture.rs @@ -1,7 +1,8 @@ //! Issue #1564 (decision 17, C4/C5), gate 1: the byte-for-byte reference the SDK's //! `engine.controls()` is held to. //! -//! The control core (`controls::list`) walks the #1560 parity session (`common::model_at`) and this +//! The control core (`controls::list`) walks the #1560 parity session (`common::model_at`, with one +//! stored fader ride added so that a row is `automated`: [`fixture_session`]) and this //! test writes each row it yields -- address, class, descriptor facts and committed value, in list //! order -- beside the canonical session document, as `fixtures/control-list.json`. The SDK eval //! (`sdk/test/control-list-evals.mjs`) boots the headless engine on that document and compares what @@ -115,7 +116,7 @@ fn row_json(row: &ControlRow) -> String { \"class\": \"{}\", \"kind\": \"{}\", \"unit\": \"{}\", \"scope\": \"{}\", \ \"family\": \"{}\", \"active\": {}, \"automatable\": {}, \"minimumBits\": \"{:08x}\", \ \"maximumBits\": \"{:08x}\", \"defaultBits\": \"{:08x}\", \"valueBits\": \"{:08x}\", \ - \"choices\": {} }}", + \"choices\": {}, \"automated\": {} }}", owner_json(&address.owner), section_json(&address.section), address.control, @@ -133,12 +134,42 @@ fn row_json(row: &ControlRow) -> String { descriptor.default.to_bits(), row.value.to_bits(), descriptor.choices.len(), + row.automated, ) } +/// The parity session with the submix `b`'s right fader lane ridden by a flat stored automation +/// whose hand offset is `+1.5` dB (#1650 D4): its `fader_db` right row is `automated` and reports +/// the offset, and its left row is a plain fader row beside it. +fn fixture_session() -> session::SessionModel { + let mut model = model_at(RATE); + model.automation.push(session::Automation { + id: session::StableId::parse("b-fader-ride").expect("automation ID"), + target: controls::ControlAddress { + owner: Owner::Strip(session::StableId::parse("b").expect("submix ID")), + section: Section::Builtins, + control: 5, + lane: ParameterChannel::Right, + }, + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: 0, + end_sample: u64::from(RATE), + start_value: -4.0, + end_value: -4.0, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset { + left_db: 0.0, + right_db: 1.5, + }, + }); + model +} + /// The fixture text: the canonical session document and the native list of it. fn render() -> String { - let document = canonical_session_json(&model_at(RATE)).expect("canonical parity session"); + let document = canonical_session_json(&fixture_session()).expect("canonical parity session"); // The model the host boots is the parse of that document, whose routes are in ID order: the // order of the fixture's rows is the booted model's, not the builder's. let model = parse_session_json(&document).expect("the canonical document parses"); diff --git a/hosts/host-web/tests/fixtures/control-list.json b/hosts/host-web/tests/fixtures/control-list.json index 413b99b26..22a44578e 100644 --- a/hosts/host-web/tests/fixtures/control-list.json +++ b/hosts/host-web/tests/fixtures/control-list.json @@ -1,407 +1,407 @@ { "sampleRate": 48000, - "session": "{\n \"schema_version\": 1,\n \"session_id\": \"web-browser-one-track\",\n \"revision\": \"1\",\n \"sample_rate_hz\": 48000,\n \"quantum_frames\": 128,\n \"render_profile\": {\n \"id\": \"native\",\n \"mode\": \"single_thread\"\n },\n \"output_profile\": {\n \"id\": \"main\",\n \"channels\": 2,\n \"sample_format\": \"f32_planar\"\n },\n \"sources\": [\n {\n \"id\": \"src-t1\",\n \"content\": \"blake3:18e5038d588b861a1754abcd0e0cc0a943fe983d4ac112232a972a4d9b5c28c5\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"9216\"\n },\n {\n \"id\": \"src-t2\",\n \"content\": \"blake3:18e5038d588b861a1754abcd0e0cc0a943fe983d4ac112232a972a4d9b5c28c5\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"9216\"\n },\n {\n \"id\": \"src-t3\",\n \"content\": \"blake3:18e5038d588b861a1754abcd0e0cc0a943fe983d4ac112232a972a4d9b5c28c5\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"9216\"\n }\n ],\n \"console\": {\n \"pre_insert\": [\n {\n \"slot\": \"pre-comp\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.compressor\"\n },\n \"quality\": \"normal\",\n \"link_mode\": \"dual_mono\"\n }\n ],\n \"post_insert\": [\n {\n \"slot\": \"post-clip\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.soft-clip\"\n },\n \"quality\": \"normal\",\n \"link_mode\": \"dual_mono\"\n }\n ]\n },\n \"tracks\": [\n {\n \"id\": \"t1\",\n \"source_id\": \"src-t1\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"pre-comp\",\n \"bypass\": false,\n \"params\": []\n },\n {\n \"slot\": \"post-clip\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.parametric-eq\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 1,\n \"channel\": \"both\",\n \"unit\": \"linear\",\n \"value\": 1.0\n },\n {\n \"parameter_id\": 2,\n \"channel\": \"both\",\n \"unit\": \"linear\",\n \"value\": 2.0\n },\n {\n \"parameter_id\": 3,\n \"channel\": \"both\",\n \"unit\": \"hz\",\n \"value\": 1000.0\n },\n {\n \"parameter_id\": 4,\n \"channel\": \"both\",\n \"unit\": \"db\",\n \"value\": 0.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"comp\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.compressor\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -2.0,\n \"right_db\": -2.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"pan\": {\n \"left\": -0.8,\n \"right\": 0.6,\n \"smoothing_samples\": 200\n }\n },\n {\n \"id\": \"t2\",\n \"source_id\": \"src-t2\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"pre-comp\",\n \"bypass\": false,\n \"params\": []\n },\n {\n \"slot\": \"post-clip\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"gate\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.gate-expander\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 6,\n \"channel\": \"both\",\n \"unit\": \"milliseconds\",\n \"value\": 0.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"clip\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.soft-clip\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"shaper\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.transient-shaper\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -3.0,\n \"right_db\": -3.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"pan\": {\n \"left\": -0.5,\n \"right\": 0.4,\n \"smoothing_samples\": 0\n }\n },\n {\n \"id\": \"t3\",\n \"source_id\": \"src-t3\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"pre-comp\",\n \"bypass\": false,\n \"params\": []\n },\n {\n \"slot\": \"post-clip\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"multi\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.multiband-compressor\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"limiter\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.true-peak-limiter\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"delay\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.delay\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -4.0,\n \"right_db\": -4.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"matrix\": {\n \"ll\": 0.9,\n \"lr\": 0.1,\n \"rl\": 0.2,\n \"rr\": 0.8,\n \"smoothing_samples\": 0\n }\n }\n ],\n \"submixes\": [\n {\n \"id\": \"b\",\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"pre-comp\",\n \"bypass\": true,\n \"params\": []\n },\n {\n \"slot\": \"post-clip\",\n \"bypass\": true,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": []\n },\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0,\n \"smoothing_samples\": 0\n }\n }\n ],\n \"vcas\": [\n {\n \"id\": \"v\",\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"members\": [\n \"t2\"\n ]\n }\n ],\n \"control_smoothing\": {\n \"kind\": \"default\"\n },\n \"outputs\": [\n {\n \"id\": \"main-out\"\n }\n ],\n \"routes\": [\n {\n \"id\": \"b-main\",\n \"source\": {\n \"kind\": \"submix\",\n \"submix_id\": \"b\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"t1-b\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"t1\",\n \"tap\": \"pre_fader\"\n },\n \"destination\": {\n \"kind\": \"submix_input\",\n \"submix_id\": \"b\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": true\n },\n {\n \"id\": \"t1-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"t1\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"t2-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"t2\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"t3-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"t3\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n }\n ],\n \"automation\": []\n}\n", + "session": "{\n \"schema_version\": 1,\n \"session_id\": \"web-browser-one-track\",\n \"revision\": \"1\",\n \"sample_rate_hz\": 48000,\n \"quantum_frames\": 128,\n \"render_profile\": {\n \"id\": \"native\",\n \"mode\": \"single_thread\"\n },\n \"output_profile\": {\n \"id\": \"main\",\n \"channels\": 2,\n \"sample_format\": \"f32_planar\"\n },\n \"sources\": [\n {\n \"id\": \"src-t1\",\n \"content\": \"blake3:18e5038d588b861a1754abcd0e0cc0a943fe983d4ac112232a972a4d9b5c28c5\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"9216\"\n },\n {\n \"id\": \"src-t2\",\n \"content\": \"blake3:18e5038d588b861a1754abcd0e0cc0a943fe983d4ac112232a972a4d9b5c28c5\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"9216\"\n },\n {\n \"id\": \"src-t3\",\n \"content\": \"blake3:18e5038d588b861a1754abcd0e0cc0a943fe983d4ac112232a972a4d9b5c28c5\",\n \"channels\": 2,\n \"bit_depth\": \"32f\",\n \"frames\": \"9216\"\n }\n ],\n \"console\": {\n \"pre_insert\": [\n {\n \"slot\": \"pre-comp\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.compressor\"\n },\n \"quality\": \"normal\",\n \"link_mode\": \"dual_mono\"\n }\n ],\n \"post_insert\": [\n {\n \"slot\": \"post-clip\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.soft-clip\"\n },\n \"quality\": \"normal\",\n \"link_mode\": \"dual_mono\"\n }\n ]\n },\n \"tracks\": [\n {\n \"id\": \"t1\",\n \"source_id\": \"src-t1\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"pre-comp\",\n \"bypass\": false,\n \"params\": []\n },\n {\n \"slot\": \"post-clip\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"eq\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.parametric-eq\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 1,\n \"channel\": \"both\",\n \"unit\": \"linear\",\n \"value\": 1.0\n },\n {\n \"parameter_id\": 2,\n \"channel\": \"both\",\n \"unit\": \"linear\",\n \"value\": 2.0\n },\n {\n \"parameter_id\": 3,\n \"channel\": \"both\",\n \"unit\": \"hz\",\n \"value\": 1000.0\n },\n {\n \"parameter_id\": 4,\n \"channel\": \"both\",\n \"unit\": \"db\",\n \"value\": 0.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"comp\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.compressor\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -2.0,\n \"right_db\": -2.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"pan\": {\n \"left\": -0.8,\n \"right\": 0.6,\n \"smoothing_samples\": 200\n }\n },\n {\n \"id\": \"t2\",\n \"source_id\": \"src-t2\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"pre-comp\",\n \"bypass\": false,\n \"params\": []\n },\n {\n \"slot\": \"post-clip\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"gate\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.gate-expander\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [\n {\n \"parameter_id\": 6,\n \"channel\": \"both\",\n \"unit\": \"milliseconds\",\n \"value\": 0.0\n }\n ],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"clip\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.soft-clip\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"shaper\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.transient-shaper\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -3.0,\n \"right_db\": -3.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"pan\": {\n \"left\": -0.5,\n \"right\": 0.4,\n \"smoothing_samples\": 0\n }\n },\n {\n \"id\": \"t3\",\n \"source_id\": \"src-t3\",\n \"left_source_channel\": 0,\n \"right_source_channel\": 1,\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"pre-comp\",\n \"bypass\": false,\n \"params\": []\n },\n {\n \"slot\": \"post-clip\",\n \"bypass\": false,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": [\n {\n \"id\": \"multi\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.multiband-compressor\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"limiter\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.true-peak-limiter\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n },\n {\n \"id\": \"delay\",\n \"identity\": {\n \"kind\": \"native\",\n \"effect_id\": \"miso.delay\"\n },\n \"quality\": \"normal\",\n \"bypass\": false,\n \"link_mode\": \"dual_mono\",\n \"params\": [],\n \"sidechain\": {\n \"kind\": \"none\"\n }\n }\n ]\n },\n \"fader\": {\n \"left_db\": -4.0,\n \"right_db\": -4.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"matrix\": {\n \"ll\": 0.9,\n \"lr\": 0.1,\n \"rl\": 0.2,\n \"rr\": 0.8,\n \"smoothing_samples\": 0\n }\n }\n ],\n \"submixes\": [\n {\n \"id\": \"b\",\n \"builtins\": {\n \"left\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n },\n \"right\": {\n \"polarity_invert\": false,\n \"trim_db\": 0.0,\n \"hpf_hz\": 0.0,\n \"lpf_hz\": 0.0,\n \"delay_samples\": 0\n }\n },\n \"console\": [\n {\n \"slot\": \"pre-comp\",\n \"bypass\": true,\n \"params\": []\n },\n {\n \"slot\": \"post-clip\",\n \"bypass\": true,\n \"params\": []\n }\n ],\n \"inserts\": {\n \"effects\": []\n },\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0,\n \"smoothing_samples\": 0\n }\n }\n ],\n \"vcas\": [\n {\n \"id\": \"v\",\n \"fader\": {\n \"left_db\": 0.0,\n \"right_db\": 0.0,\n \"left_mute\": false,\n \"right_mute\": false\n },\n \"members\": [\n \"t2\"\n ]\n }\n ],\n \"control_smoothing\": {\n \"kind\": \"default\"\n },\n \"outputs\": [\n {\n \"id\": \"main-out\"\n }\n ],\n \"routes\": [\n {\n \"id\": \"b-main\",\n \"source\": {\n \"kind\": \"submix\",\n \"submix_id\": \"b\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"t1-b\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"t1\",\n \"tap\": \"pre_fader\"\n },\n \"destination\": {\n \"kind\": \"submix_input\",\n \"submix_id\": \"b\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": true\n },\n {\n \"id\": \"t1-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"t1\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"t2-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"t2\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n },\n {\n \"id\": \"t3-main\",\n \"source\": {\n \"kind\": \"track\",\n \"track_id\": \"t3\",\n \"tap\": \"post_pan\"\n },\n \"destination\": {\n \"kind\": \"output_input\",\n \"output_id\": \"main-out\"\n },\n \"channel_matrix\": {\n \"ll\": 1.0,\n \"lr\": 0.0,\n \"rl\": 0.0,\n \"rr\": 1.0\n },\n \"gain_db\": 0.0,\n \"mute\": false,\n \"follows_mute\": false\n }\n ],\n \"automation\": [\n {\n \"id\": \"b-fader-ride\",\n \"target\": {\n \"owner\": \"strip\",\n \"owner_id\": \"b\",\n \"section\": \"builtins\",\n \"control\": 5,\n \"lane\": \"right\"\n },\n \"segments\": [\n {\n \"shape\": \"step\",\n \"start_sample\": \"0\",\n \"end_sample\": \"48000\",\n \"start_value\": -4.0,\n \"end_value\": -4.0,\n \"unit\": \"db\"\n }\n ],\n \"offset\": {\n \"left_db\": 0.0,\n \"right_db\": 1.5\n }\n }\n ]\n}\n", "rows": [ - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 2 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "solo", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_window_samples", "class": "live", "kind": "continuous", "unit": "samples", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "4f800000", "defaultBits": "00000000", "valueBits": "43480000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "master", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 65, "lane": "left", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 65, "lane": "right", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 1, "lane": "left", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 1, "lane": "right", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 2, "lane": "left", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 2, "lane": "right", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 4, "lane": "left", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 4, "lane": "right", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 5, "lane": "left", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 5, "lane": "right", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 6, "lane": "left", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 6, "lane": "right", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 7, "lane": "both", "name": "matrix_ll", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 8, "lane": "both", "name": "matrix_lr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 9, "lane": "both", "name": "matrix_rl", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 10, "lane": "both", "name": "matrix_rr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 11, "lane": "left", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 11, "lane": "right", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 12, "lane": "left", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "bf4ccccd", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 12, "lane": "right", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f19999a", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "left", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "right", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "left", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "right", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "left", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "right", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "left", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "right", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "left", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "right", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "left", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "right", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "left", "name": "band_1_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "right", "name": "band_1_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 2, "lane": "left", "name": "band_1_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "40000000", "choices": 6 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 2, "lane": "right", "name": "band_1_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "40000000", "choices": 6 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 3, "lane": "left", "name": "band_1_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "42a00000", "valueBits": "447a0000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 3, "lane": "right", "name": "band_1_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "42a00000", "valueBits": "447a0000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 4, "lane": "left", "name": "band_1_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 4, "lane": "right", "name": "band_1_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 5, "lane": "left", "name": "band_1_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 5, "lane": "right", "name": "band_1_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 6, "lane": "left", "name": "band_1_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 6, "lane": "right", "name": "band_1_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 17, "lane": "left", "name": "band_2_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 17, "lane": "right", "name": "band_2_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 18, "lane": "left", "name": "band_2_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 6 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 18, "lane": "right", "name": "band_2_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 6 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 19, "lane": "left", "name": "band_2_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "43c80000", "valueBits": "43c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 19, "lane": "right", "name": "band_2_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "43c80000", "valueBits": "43c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 20, "lane": "left", "name": "band_2_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 20, "lane": "right", "name": "band_2_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 21, "lane": "left", "name": "band_2_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 21, "lane": "right", "name": "band_2_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 22, "lane": "left", "name": "band_2_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 22, "lane": "right", "name": "band_2_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 33, "lane": "left", "name": "band_3_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 33, "lane": "right", "name": "band_3_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 34, "lane": "left", "name": "band_3_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 6 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 34, "lane": "right", "name": "band_3_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 6 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 35, "lane": "left", "name": "band_3_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "44fa0000", "valueBits": "44fa0000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 35, "lane": "right", "name": "band_3_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "44fa0000", "valueBits": "44fa0000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 36, "lane": "left", "name": "band_3_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 36, "lane": "right", "name": "band_3_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 37, "lane": "left", "name": "band_3_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 37, "lane": "right", "name": "band_3_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 38, "lane": "left", "name": "band_3_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 38, "lane": "right", "name": "band_3_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 49, "lane": "left", "name": "band_4_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 49, "lane": "right", "name": "band_4_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 50, "lane": "left", "name": "band_4_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 6 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 50, "lane": "right", "name": "band_4_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 6 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 51, "lane": "left", "name": "band_4_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "461c4000", "valueBits": "461c4000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 51, "lane": "right", "name": "band_4_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "461c4000", "valueBits": "461c4000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 52, "lane": "left", "name": "band_4_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 52, "lane": "right", "name": "band_4_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 53, "lane": "left", "name": "band_4_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 53, "lane": "right", "name": "band_4_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 54, "lane": "left", "name": "band_4_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 54, "lane": "right", "name": "band_4_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 65, "lane": "left", "name": "hpf_enabled", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 65, "lane": "right", "name": "hpf_enabled", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 66, "lane": "left", "name": "hpf_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "42a00000", "valueBits": "42a00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 66, "lane": "right", "name": "hpf_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "42a00000", "valueBits": "42a00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 67, "lane": "left", "name": "hpf_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 67, "lane": "right", "name": "hpf_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 81, "lane": "left", "name": "lpf_enabled", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 81, "lane": "right", "name": "lpf_enabled", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 82, "lane": "left", "name": "lpf_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "468ca000", "valueBits": "468ca000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 82, "lane": "right", "name": "lpf_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "468ca000", "valueBits": "468ca000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 83, "lane": "left", "name": "lpf_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 83, "lane": "right", "name": "lpf_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 1, "lane": "left", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 1, "lane": "right", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 2, "lane": "left", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 2, "lane": "right", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 3, "lane": "left", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 3, "lane": "right", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 4, "lane": "left", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 4, "lane": "right", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 5, "lane": "left", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 5, "lane": "right", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 6, "lane": "left", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 6, "lane": "right", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 7, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 7, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 2147483648, "lane": "both", "name": "link_mode", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40400000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 3 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "left", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "right", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "left", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 2 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "solo", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_window_samples", "class": "live", "kind": "continuous", "unit": "samples", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "4f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "master", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0400000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0400000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 65, "lane": "left", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 65, "lane": "right", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 1, "lane": "left", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 1, "lane": "right", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 2, "lane": "left", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 2, "lane": "right", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 4, "lane": "left", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 4, "lane": "right", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 5, "lane": "left", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0400000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 5, "lane": "right", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0400000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 6, "lane": "left", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 6, "lane": "right", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 7, "lane": "both", "name": "matrix_ll", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 8, "lane": "both", "name": "matrix_lr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 9, "lane": "both", "name": "matrix_rl", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 10, "lane": "both", "name": "matrix_rr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 11, "lane": "left", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 11, "lane": "right", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 12, "lane": "left", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "bf000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 12, "lane": "right", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3ecccccd", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "left", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "right", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "left", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "right", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "left", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "right", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "left", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "right", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "left", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "right", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "left", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "right", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 1, "lane": "left", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c2200000", "valueBits": "c2200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 1, "lane": "right", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c2200000", "valueBits": "c2200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 2, "lane": "left", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 2, "lane": "right", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 3, "lane": "left", "name": "range_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "42c00000", "defaultBits": "42a00000", "valueBits": "42a00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 3, "lane": "right", "name": "range_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "42c00000", "defaultBits": "42a00000", "valueBits": "42a00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 4, "lane": "left", "name": "hysteresis_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 4, "lane": "right", "name": "hysteresis_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 5, "lane": "left", "name": "attack_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3dcccccd", "maximumBits": "42480000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 5, "lane": "right", "name": "attack_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3dcccccd", "maximumBits": "42480000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 6, "lane": "left", "name": "hold_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "447a0000", "defaultBits": "42c80000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 6, "lane": "right", "name": "hold_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "447a0000", "defaultBits": "42c80000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 7, "lane": "left", "name": "release_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "40a00000", "maximumBits": "44fa0000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 7, "lane": "right", "name": "release_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "40a00000", "maximumBits": "44fa0000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 2147483648, "lane": "both", "name": "link_mode", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40400000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 3 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 1, "lane": "left", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 1, "lane": "right", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 2, "lane": "left", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 3, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 1, "lane": "left", "name": "attack_amount", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 1, "lane": "right", "name": "attack_amount", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 2, "lane": "left", "name": "sustain_amount", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 2, "lane": "right", "name": "sustain_amount", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 3, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 2147483648, "lane": "both", "name": "link_mode", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40400000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 3 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "left", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "right", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "left", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 2 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "solo", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_window_samples", "class": "live", "kind": "continuous", "unit": "samples", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "4f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "master", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 65, "lane": "left", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 65, "lane": "right", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 1, "lane": "left", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 1, "lane": "right", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 2, "lane": "left", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 2, "lane": "right", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 4, "lane": "left", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 4, "lane": "right", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 5, "lane": "left", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 5, "lane": "right", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 6, "lane": "left", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 6, "lane": "right", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 7, "lane": "both", "name": "matrix_ll", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f666666", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 8, "lane": "both", "name": "matrix_lr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3dcccccd", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 9, "lane": "both", "name": "matrix_rl", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3e4ccccd", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 10, "lane": "both", "name": "matrix_rr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f4ccccd", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 11, "lane": "left", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 11, "lane": "right", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 12, "lane": "left", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 12, "lane": "right", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "left", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "right", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "left", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "right", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "left", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "right", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "left", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "right", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "left", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "right", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "left", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "right", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 0, "lane": "both", "name": "bypass", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 1, "lane": "left", "name": "crossover_hz", "class": "rebuildOnly", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "42a00000", "maximumBits": "45fa0000", "defaultBits": "447a0000", "valueBits": "447a0000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 1, "lane": "right", "name": "crossover_hz", "class": "rebuildOnly", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "42a00000", "maximumBits": "45fa0000", "defaultBits": "447a0000", "valueBits": "447a0000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 3, "lane": "left", "name": "low_threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 3, "lane": "right", "name": "low_threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 4, "lane": "left", "name": "low_ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 4, "lane": "right", "name": "low_ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 5, "lane": "left", "name": "low_attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 5, "lane": "right", "name": "low_attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 6, "lane": "left", "name": "low_release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 6, "lane": "right", "name": "low_release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 7, "lane": "left", "name": "low_makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 7, "lane": "right", "name": "low_makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 8, "lane": "left", "name": "high_threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 8, "lane": "right", "name": "high_threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 9, "lane": "left", "name": "high_ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 9, "lane": "right", "name": "high_ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 10, "lane": "left", "name": "high_attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 10, "lane": "right", "name": "high_attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 11, "lane": "left", "name": "high_release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 11, "lane": "right", "name": "high_release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 12, "lane": "left", "name": "high_makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 12, "lane": "right", "name": "high_makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 2147483648, "lane": "both", "name": "link_mode", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40400000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 3 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 1, "lane": "left", "name": "ceiling_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "00000000", "defaultBits": "bf800000", "valueBits": "bf800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 1, "lane": "right", "name": "ceiling_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "00000000", "defaultBits": "bf800000", "valueBits": "bf800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 2, "lane": "left", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "44fa0000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 2, "lane": "right", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "44fa0000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 3, "lane": "left", "name": "lookahead_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "41200000", "defaultBits": "40a00000", "valueBits": "40a00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 3, "lane": "right", "name": "lookahead_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "41200000", "defaultBits": "40a00000", "valueBits": "40a00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 2147483648, "lane": "both", "name": "link_mode", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40000000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 2 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 0, "lane": "both", "name": "bypass", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 1, "lane": "left", "name": "delay_time_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "44fa0000", "defaultBits": "437a0000", "valueBits": "437a0000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 1, "lane": "right", "name": "delay_time_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "44fa0000", "defaultBits": "437a0000", "valueBits": "437a0000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 2, "lane": "left", "name": "feedback", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "bf733333", "maximumBits": "3f733333", "defaultBits": "3eb33333", "valueBits": "3eb33333", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 2, "lane": "right", "name": "feedback", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "bf733333", "maximumBits": "3f733333", "defaultBits": "3eb33333", "valueBits": "3eb33333", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 3, "lane": "left", "name": "damping", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f7eb852", "defaultBits": "3e800000", "valueBits": "3e800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 3, "lane": "right", "name": "damping", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f7eb852", "defaultBits": "3e800000", "valueBits": "3e800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 4, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3eb33333", "valueBits": "3eb33333", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 4, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3eb33333", "valueBits": "3eb33333", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 5, "lane": "both", "name": "cross_feedback", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "left", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "right", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "left", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 2 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_window_samples", "class": "live", "kind": "continuous", "unit": "samples", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "4f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "master", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 65, "lane": "left", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 65, "lane": "right", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 1, "lane": "left", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 1, "lane": "right", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 2, "lane": "left", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 2, "lane": "right", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 4, "lane": "left", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 4, "lane": "right", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 5, "lane": "left", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 5, "lane": "right", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 6, "lane": "left", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 6, "lane": "right", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 7, "lane": "both", "name": "matrix_ll", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 8, "lane": "both", "name": "matrix_lr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 9, "lane": "both", "name": "matrix_rl", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 10, "lane": "both", "name": "matrix_rr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 11, "lane": "left", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 11, "lane": "right", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 12, "lane": "left", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 12, "lane": "right", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "left", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "right", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "left", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "right", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "left", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "right", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "left", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "right", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "left", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "right", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "left", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "right", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "left", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "right", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "left", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "gain_db", "class": "rebuildOnly", "kind": "continuous", "unit": "db", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "matrix_ll", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "matrix_lr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 5, "lane": "both", "name": "matrix_rl", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 6, "lane": "both", "name": "matrix_rr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 7, "lane": "both", "name": "follows_mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": false, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "matrix_ll", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "matrix_lr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 5, "lane": "both", "name": "matrix_rl", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 6, "lane": "both", "name": "matrix_rr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 7, "lane": "both", "name": "follows_mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "gain_db", "class": "rebuildOnly", "kind": "continuous", "unit": "db", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "matrix_ll", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "matrix_lr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 5, "lane": "both", "name": "matrix_rl", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 6, "lane": "both", "name": "matrix_rr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 7, "lane": "both", "name": "follows_mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": false, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "gain_db", "class": "rebuildOnly", "kind": "continuous", "unit": "db", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "matrix_ll", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "matrix_lr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 5, "lane": "both", "name": "matrix_rl", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 6, "lane": "both", "name": "matrix_rr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 7, "lane": "both", "name": "follows_mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": false, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "gain_db", "class": "rebuildOnly", "kind": "continuous", "unit": "db", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "matrix_ll", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "matrix_lr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 5, "lane": "both", "name": "matrix_rl", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 6, "lane": "both", "name": "matrix_rr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 7, "lane": "both", "name": "follows_mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": false, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "vca", "id": "v" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "name": "offset_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "vca", "id": "v" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "name": "offset_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "vca", "id": "v" }, "section": { "kind": "own" }, "control": 2, "lane": "left", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "vca", "id": "v" }, "section": { "kind": "own" }, "control": 2, "lane": "right", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0 }, - { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "fader_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "447a0000", "defaultBits": "41a00000", "valueBits": "41a00000", "choices": 0 }, - { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "mute_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "447a0000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0 }, - { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "447a0000", "defaultBits": "41a00000", "valueBits": "41a00000", "choices": 0 } + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 2, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "solo", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_window_samples", "class": "live", "kind": "continuous", "unit": "samples", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "4f800000", "defaultBits": "00000000", "valueBits": "43480000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "master", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 65, "lane": "left", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "own" }, "control": 65, "lane": "right", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 1, "lane": "left", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 1, "lane": "right", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 2, "lane": "left", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 2, "lane": "right", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 4, "lane": "left", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 4, "lane": "right", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 5, "lane": "left", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 5, "lane": "right", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 6, "lane": "left", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 6, "lane": "right", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 7, "lane": "both", "name": "matrix_ll", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 8, "lane": "both", "name": "matrix_lr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 9, "lane": "both", "name": "matrix_rl", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 10, "lane": "both", "name": "matrix_rr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 11, "lane": "left", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 11, "lane": "right", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 12, "lane": "left", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "bf4ccccd", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "builtins" }, "control": 12, "lane": "right", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f19999a", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "left", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "right", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "left", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "right", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "left", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "right", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "left", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "right", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "left", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "right", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "left", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "right", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "left", "name": "band_1_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 1, "lane": "right", "name": "band_1_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 2, "lane": "left", "name": "band_1_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "40000000", "choices": 6, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 2, "lane": "right", "name": "band_1_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "40000000", "choices": 6, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 3, "lane": "left", "name": "band_1_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "42a00000", "valueBits": "447a0000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 3, "lane": "right", "name": "band_1_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "42a00000", "valueBits": "447a0000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 4, "lane": "left", "name": "band_1_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 4, "lane": "right", "name": "band_1_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 5, "lane": "left", "name": "band_1_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 5, "lane": "right", "name": "band_1_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 6, "lane": "left", "name": "band_1_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 6, "lane": "right", "name": "band_1_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 17, "lane": "left", "name": "band_2_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 17, "lane": "right", "name": "band_2_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 18, "lane": "left", "name": "band_2_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 6, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 18, "lane": "right", "name": "band_2_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 6, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 19, "lane": "left", "name": "band_2_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "43c80000", "valueBits": "43c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 19, "lane": "right", "name": "band_2_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "43c80000", "valueBits": "43c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 20, "lane": "left", "name": "band_2_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 20, "lane": "right", "name": "band_2_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 21, "lane": "left", "name": "band_2_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 21, "lane": "right", "name": "band_2_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 22, "lane": "left", "name": "band_2_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 22, "lane": "right", "name": "band_2_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 33, "lane": "left", "name": "band_3_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 33, "lane": "right", "name": "band_3_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 34, "lane": "left", "name": "band_3_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 6, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 34, "lane": "right", "name": "band_3_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 6, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 35, "lane": "left", "name": "band_3_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "44fa0000", "valueBits": "44fa0000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 35, "lane": "right", "name": "band_3_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "44fa0000", "valueBits": "44fa0000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 36, "lane": "left", "name": "band_3_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 36, "lane": "right", "name": "band_3_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 37, "lane": "left", "name": "band_3_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 37, "lane": "right", "name": "band_3_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 38, "lane": "left", "name": "band_3_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 38, "lane": "right", "name": "band_3_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 49, "lane": "left", "name": "band_4_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 49, "lane": "right", "name": "band_4_enabled", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 50, "lane": "left", "name": "band_4_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 6, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 50, "lane": "right", "name": "band_4_kind", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40c00000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 6, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 51, "lane": "left", "name": "band_4_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "461c4000", "valueBits": "461c4000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 51, "lane": "right", "name": "band_4_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "461c4000", "valueBits": "461c4000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 52, "lane": "left", "name": "band_4_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 52, "lane": "right", "name": "band_4_gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 53, "lane": "left", "name": "band_4_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 53, "lane": "right", "name": "band_4_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 54, "lane": "left", "name": "band_4_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 54, "lane": "right", "name": "band_4_shelf_slope", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 65, "lane": "left", "name": "hpf_enabled", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 65, "lane": "right", "name": "hpf_enabled", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 66, "lane": "left", "name": "hpf_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "42a00000", "valueBits": "42a00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 66, "lane": "right", "name": "hpf_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "42a00000", "valueBits": "42a00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 67, "lane": "left", "name": "hpf_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 67, "lane": "right", "name": "hpf_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 81, "lane": "left", "name": "lpf_enabled", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 81, "lane": "right", "name": "lpf_enabled", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 82, "lane": "left", "name": "lpf_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "468ca000", "valueBits": "468ca000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 82, "lane": "right", "name": "lpf_frequency_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "469c4000", "defaultBits": "468ca000", "valueBits": "468ca000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 83, "lane": "left", "name": "lpf_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "eq" }, "control": 83, "lane": "right", "name": "lpf_q", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "41900000", "defaultBits": "3f3504f3", "valueBits": "3f3504f3", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 1, "lane": "left", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 1, "lane": "right", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 2, "lane": "left", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 2, "lane": "right", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 3, "lane": "left", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 3, "lane": "right", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 4, "lane": "left", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 4, "lane": "right", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 5, "lane": "left", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 5, "lane": "right", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 6, "lane": "left", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 6, "lane": "right", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 7, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 7, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "inserts", "effect": "comp" }, "control": 2147483648, "lane": "both", "name": "link_mode", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40400000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 3, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "left", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "right", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "left", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t1" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 2, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "solo", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_window_samples", "class": "live", "kind": "continuous", "unit": "samples", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "4f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "master", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0400000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0400000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 65, "lane": "left", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "own" }, "control": 65, "lane": "right", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 1, "lane": "left", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 1, "lane": "right", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 2, "lane": "left", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 2, "lane": "right", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 4, "lane": "left", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 4, "lane": "right", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 5, "lane": "left", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0400000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 5, "lane": "right", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0400000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 6, "lane": "left", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 6, "lane": "right", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 7, "lane": "both", "name": "matrix_ll", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 8, "lane": "both", "name": "matrix_lr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 9, "lane": "both", "name": "matrix_rl", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 10, "lane": "both", "name": "matrix_rr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 11, "lane": "left", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 11, "lane": "right", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 12, "lane": "left", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "bf000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "builtins" }, "control": 12, "lane": "right", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3ecccccd", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "left", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "right", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "left", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "right", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "left", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "right", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "left", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "right", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "left", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "right", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "left", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "right", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 1, "lane": "left", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c2200000", "valueBits": "c2200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 1, "lane": "right", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c2200000", "valueBits": "c2200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 2, "lane": "left", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 2, "lane": "right", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 3, "lane": "left", "name": "range_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "42c00000", "defaultBits": "42a00000", "valueBits": "42a00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 3, "lane": "right", "name": "range_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "42c00000", "defaultBits": "42a00000", "valueBits": "42a00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 4, "lane": "left", "name": "hysteresis_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 4, "lane": "right", "name": "hysteresis_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 5, "lane": "left", "name": "attack_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3dcccccd", "maximumBits": "42480000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 5, "lane": "right", "name": "attack_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3dcccccd", "maximumBits": "42480000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 6, "lane": "left", "name": "hold_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "447a0000", "defaultBits": "42c80000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 6, "lane": "right", "name": "hold_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "447a0000", "defaultBits": "42c80000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 7, "lane": "left", "name": "release_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "40a00000", "maximumBits": "44fa0000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 7, "lane": "right", "name": "release_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "40a00000", "maximumBits": "44fa0000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "gate" }, "control": 2147483648, "lane": "both", "name": "link_mode", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40400000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 3, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 1, "lane": "left", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 1, "lane": "right", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 2, "lane": "left", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 3, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "clip" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 1, "lane": "left", "name": "attack_amount", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 1, "lane": "right", "name": "attack_amount", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 2, "lane": "left", "name": "sustain_amount", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 2, "lane": "right", "name": "sustain_amount", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 3, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "inserts", "effect": "shaper" }, "control": 2147483648, "lane": "both", "name": "link_mode", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40400000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 3, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "left", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "right", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "left", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t2" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 2, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "solo", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_window_samples", "class": "live", "kind": "continuous", "unit": "samples", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "4f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "master", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 65, "lane": "left", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "own" }, "control": 65, "lane": "right", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 1, "lane": "left", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 1, "lane": "right", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 2, "lane": "left", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 2, "lane": "right", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 4, "lane": "left", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 4, "lane": "right", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 5, "lane": "left", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 5, "lane": "right", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "c0800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 6, "lane": "left", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 6, "lane": "right", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 7, "lane": "both", "name": "matrix_ll", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f666666", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 8, "lane": "both", "name": "matrix_lr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3dcccccd", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 9, "lane": "both", "name": "matrix_rl", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3e4ccccd", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 10, "lane": "both", "name": "matrix_rr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f4ccccd", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 11, "lane": "left", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 11, "lane": "right", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 12, "lane": "left", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "builtins" }, "control": 12, "lane": "right", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "left", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "right", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "left", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "right", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "left", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "right", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "left", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "right", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "left", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "right", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "left", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "right", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 0, "lane": "both", "name": "bypass", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 1, "lane": "left", "name": "crossover_hz", "class": "rebuildOnly", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "42a00000", "maximumBits": "45fa0000", "defaultBits": "447a0000", "valueBits": "447a0000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 1, "lane": "right", "name": "crossover_hz", "class": "rebuildOnly", "kind": "continuous", "unit": "hz", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "42a00000", "maximumBits": "45fa0000", "defaultBits": "447a0000", "valueBits": "447a0000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 3, "lane": "left", "name": "low_threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 3, "lane": "right", "name": "low_threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 4, "lane": "left", "name": "low_ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 4, "lane": "right", "name": "low_ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 5, "lane": "left", "name": "low_attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 5, "lane": "right", "name": "low_attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 6, "lane": "left", "name": "low_release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 6, "lane": "right", "name": "low_release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 7, "lane": "left", "name": "low_makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 7, "lane": "right", "name": "low_makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 8, "lane": "left", "name": "high_threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 8, "lane": "right", "name": "high_threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 9, "lane": "left", "name": "high_ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 9, "lane": "right", "name": "high_ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 10, "lane": "left", "name": "high_attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 10, "lane": "right", "name": "high_attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 11, "lane": "left", "name": "high_release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 11, "lane": "right", "name": "high_release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 12, "lane": "left", "name": "high_makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 12, "lane": "right", "name": "high_makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "multi" }, "control": 2147483648, "lane": "both", "name": "link_mode", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40400000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 3, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 1, "lane": "left", "name": "ceiling_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "00000000", "defaultBits": "bf800000", "valueBits": "bf800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 1, "lane": "right", "name": "ceiling_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "00000000", "defaultBits": "bf800000", "valueBits": "bf800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 2, "lane": "left", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "44fa0000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 2, "lane": "right", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "44fa0000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 3, "lane": "left", "name": "lookahead_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "41200000", "defaultBits": "40a00000", "valueBits": "40a00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 3, "lane": "right", "name": "lookahead_ms", "class": "rebuildOnly", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "41200000", "defaultBits": "40a00000", "valueBits": "40a00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "limiter" }, "control": 2147483648, "lane": "both", "name": "link_mode", "class": "rebuildOnly", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "3f800000", "maximumBits": "40000000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 2, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 0, "lane": "both", "name": "bypass", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 1, "lane": "left", "name": "delay_time_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "44fa0000", "defaultBits": "437a0000", "valueBits": "437a0000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 1, "lane": "right", "name": "delay_time_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "44fa0000", "defaultBits": "437a0000", "valueBits": "437a0000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 2, "lane": "left", "name": "feedback", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "bf733333", "maximumBits": "3f733333", "defaultBits": "3eb33333", "valueBits": "3eb33333", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 2, "lane": "right", "name": "feedback", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "bf733333", "maximumBits": "3f733333", "defaultBits": "3eb33333", "valueBits": "3eb33333", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 3, "lane": "left", "name": "damping", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f7eb852", "defaultBits": "3e800000", "valueBits": "3e800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 3, "lane": "right", "name": "damping", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f7eb852", "defaultBits": "3e800000", "valueBits": "3e800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 4, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3eb33333", "valueBits": "3eb33333", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 4, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3eb33333", "valueBits": "3eb33333", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "inserts", "effect": "delay" }, "control": 5, "lane": "both", "name": "cross_feedback", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "left", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "right", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "left", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "t3" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "matrix_mode", "class": "live", "kind": "enumeration", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 2, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_window_samples", "class": "live", "kind": "continuous", "unit": "samples", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "4f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "master", "class": "live", "kind": "toggle", "unit": "linear", "scope": "monitor", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_fader_db", "class": "derived", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 65, "lane": "left", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "own" }, "control": 65, "lane": "right", "name": "effective_mute", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 1, "lane": "left", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 1, "lane": "right", "name": "polarity_invert", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 2, "lane": "left", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 2, "lane": "right", "name": "trim_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 3, "lane": "left", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 3, "lane": "right", "name": "hpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 4, "lane": "left", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 4, "lane": "right", "name": "lpf_hz", "class": "live", "kind": "continuous", "unit": "hz", "scope": "model", "family": "stripInput", "active": true, "automatable": true, "minimumBits": "41200000", "maximumBits": "46bb7ede", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 5, "lane": "left", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 5, "lane": "right", "name": "fader_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "3fc00000", "choices": 0, "automated": true }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 6, "lane": "left", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 6, "lane": "right", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 7, "lane": "both", "name": "matrix_ll", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 8, "lane": "both", "name": "matrix_lr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 9, "lane": "both", "name": "matrix_rl", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 10, "lane": "both", "name": "matrix_rr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 11, "lane": "left", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 11, "lane": "right", "name": "delay_samples", "class": "rebuildOnly", "kind": "continuous", "unit": "samples", "scope": "model", "family": "stripInput", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "473b8000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 12, "lane": "left", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "builtins" }, "control": 12, "lane": "right", "name": "pan", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "stripMatrix", "active": false, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "left", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 1, "lane": "right", "name": "threshold_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c2a00000", "maximumBits": "00000000", "defaultBits": "c1900000", "valueBits": "c1900000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "left", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 2, "lane": "right", "name": "ratio", "class": "live", "kind": "continuous", "unit": "ratio", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3f800000", "maximumBits": "41a00000", "defaultBits": "40800000", "valueBits": "40800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "left", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 3, "lane": "right", "name": "knee_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "41c00000", "defaultBits": "40c00000", "valueBits": "40c00000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "left", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 4, "lane": "right", "name": "attack_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "3dcccccd", "maximumBits": "43480000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "left", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 5, "lane": "right", "name": "release_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "40a00000", "maximumBits": "459c4000", "defaultBits": "42c80000", "valueBits": "42c80000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "left", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 6, "lane": "right", "name": "makeup_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "pre-comp" }, "control": 7, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 0, "lane": "both", "name": "bypass", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "left", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 1, "lane": "right", "name": "drive_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "42100000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "left", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 2, "lane": "right", "name": "output_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "c1c00000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "left", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "strip", "id": "b" }, "section": { "kind": "console", "slot": "post-clip" }, "control": 3, "lane": "right", "name": "mix", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "effect", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "gain_db", "class": "rebuildOnly", "kind": "continuous", "unit": "db", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "matrix_ll", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "matrix_lr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 5, "lane": "both", "name": "matrix_rl", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 6, "lane": "both", "name": "matrix_rr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 7, "lane": "both", "name": "follows_mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": false, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "b-main" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "gain_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "matrix_ll", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "matrix_lr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 5, "lane": "both", "name": "matrix_rl", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 6, "lane": "both", "name": "matrix_rr", "class": "live", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": true, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 7, "lane": "both", "name": "follows_mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-b" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "gain_db", "class": "rebuildOnly", "kind": "continuous", "unit": "db", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "matrix_ll", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "matrix_lr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 5, "lane": "both", "name": "matrix_rl", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 6, "lane": "both", "name": "matrix_rr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 7, "lane": "both", "name": "follows_mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": false, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t1-main" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "gain_db", "class": "rebuildOnly", "kind": "continuous", "unit": "db", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "matrix_ll", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "matrix_lr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 5, "lane": "both", "name": "matrix_rl", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 6, "lane": "both", "name": "matrix_rr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 7, "lane": "both", "name": "follows_mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": false, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t2-main" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "gain_db", "class": "rebuildOnly", "kind": "continuous", "unit": "db", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "matrix_ll", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 4, "lane": "both", "name": "matrix_lr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 5, "lane": "both", "name": "matrix_rl", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 6, "lane": "both", "name": "matrix_rr", "class": "rebuildOnly", "kind": "continuous", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "bf800000", "maximumBits": "3f800000", "defaultBits": "3f800000", "valueBits": "3f800000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 7, "lane": "both", "name": "follows_mute", "class": "rebuildOnly", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": false, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 64, "lane": "left", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "route", "id": "t3-main" }, "section": { "kind": "own" }, "control": 64, "lane": "right", "name": "effective_gate", "class": "derived", "kind": "toggle", "unit": "linear", "scope": "model", "family": "route", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "vca", "id": "v" }, "section": { "kind": "own" }, "control": 1, "lane": "left", "name": "offset_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "vca", "id": "v" }, "section": { "kind": "own" }, "control": 1, "lane": "right", "name": "offset_db", "class": "live", "kind": "continuous", "unit": "db", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "c3100000", "maximumBits": "41c00000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "vca", "id": "v" }, "section": { "kind": "own" }, "control": 2, "lane": "left", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "vca", "id": "v" }, "section": { "kind": "own" }, "control": 2, "lane": "right", "name": "mute", "class": "live", "kind": "toggle", "unit": "linear", "scope": "model", "family": "stripLevel", "active": true, "automatable": true, "minimumBits": "00000000", "maximumBits": "3f800000", "defaultBits": "00000000", "valueBits": "00000000", "choices": 0, "automated": false }, + { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 1, "lane": "both", "name": "fader_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "447a0000", "defaultBits": "41a00000", "valueBits": "41a00000", "choices": 0, "automated": false }, + { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 2, "lane": "both", "name": "mute_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "447a0000", "defaultBits": "41200000", "valueBits": "41200000", "choices": 0, "automated": false }, + { "owner": { "kind": "session" }, "section": { "kind": "own" }, "control": 3, "lane": "both", "name": "pan_ms", "class": "live", "kind": "continuous", "unit": "milliseconds", "scope": "model", "family": "setting", "active": true, "automatable": false, "minimumBits": "00000000", "maximumBits": "447a0000", "defaultBits": "41a00000", "valueBits": "41a00000", "choices": 0, "automated": false } ] } diff --git a/sdk/test/control-list-evals.mjs b/sdk/test/control-list-evals.mjs index f1a0b5ce4..ee7ec4e57 100644 --- a/sdk/test/control-list-evals.mjs +++ b/sdk/test/control-list-evals.mjs @@ -68,6 +68,7 @@ function projected(entry) { defaultBits: bits(entry.default), valueBits: bits(entry.value), choices: entry.choices.length, + automated: entry.automated, }; } @@ -130,6 +131,13 @@ describe("#1564A gate 1 -- the list is the core's", () => { try { const list = await engine.controls(); assert.equal(list.controls.length, FIXTURE.rows.length, "no row is dropped or added"); + // #1650 D4: the fixture rides `b`'s right fader, so one row is automated and its left + // neighbour is not; the projection compares the flag on every row. + assert.deepEqual( + FIXTURE.rows.filter((row) => row.automated).map((row) => [row.owner.id, row.name, row.lane]), + [["b", "fader_db", "right"]], + "the fixture has one automated row", + ); assert.deepEqual(list.controls.map(projected), FIXTURE.rows); const handles = list.controls.map((entry) => entry.handle); assert.ok(handles.every((handle, at) => handle > 0 && (at === 0 || handle > handles[at - 1])), From 9dcf288de2dca327024c4ce366a94afe76ca86d0 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 21:54:00 +0000 Subject: [PATCH 26/35] Keep a live level edit's ramp across a swap on an automated lane (#1648, #1649 U3 fold) U3 MAJOR 1: the swap carry (apply_pending, also the fused bank) reads each carried automated lane's offsets cell with ALL, counted; adopt carries the newest record and the heard S; the successor's first block applies it through offsets_jump from the heard S unless its own cell has a newer record (then 1 superseded). The strip join writes an offsets-only change into the successor's cells with the edit's own length. Gates: VCA moves pending at a swap keep their length and their superseded count; a VCA move inside a rebuild keeps its own length; heard move then rebuild; offset-only upsert inside a rebuild; obligation owed at a swap; flat-ride twin with no settle window (ramp word); a second VCA move. Lane slot 224 -> 304 B (measured, charged). Realtime policy 160; pcm_digest unchanged. #1648 and #1649 at attempt 2 of 5. Co-Authored-By: Claude Opus 5.5 --- ...assify-and-carry-fader-automation-edits.md | 73 +++ ...ca-offsets-with-stored-fader-automation.md | 32 ++ crates/builtins-compiler/src/lib.rs | 207 +++++-- crates/capi/src/runtime/live_tests.rs | 512 ++++++++++++++++++ .../tests/vca_automation_realtime.rs | 53 +- crates/host-core/src/prepare.rs | 66 ++- .../tests/stored_fader_automation.rs | 98 +++- hosts/host-web/tests/control_parity.rs | 55 +- 8 files changed, 983 insertions(+), 113 deletions(-) diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index 29f5b0240..65d5d509a 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -564,3 +564,76 @@ over `5e695ecb6` for the coordinator. all pass. - Bytes: `Adoption` (one byte) sits in `AutomatedFaderCell`'s padding and `GraphStripMove::continued_cells` in the move's padding. No existing byte gate moved. + +## U3 fold (batch 17) + +Fold of the U3 review, part U3a (render and carry), on `codex/controls-batch-17k` over +`86271e096`, uncommitted for the coordinator. **Attempt count:** #1648 is at attempt 2 of 5 with +this fold (the implementation #1648a/#1648b, then this fold). + +**MAJOR 1: a live level value pending at a swap lost its ramp, and a superseded write went +uncounted.** The plan-swap carry drain (`FaderBankProcessor::apply_pending`, which the fused bank +calls too) did not read the offsets cells, and adoption compared the successor's prepared `S` +with the predecessor's heard one, jumping over the fader length `J`. Now: + +- The carry drain reads each carried automated lane's offsets cells with `LiveSnapshot::ALL` + through `read_counted` (`LaneFaderAutomation::carry_offsets`) and keeps the newest record in + the cell (`AutomatedFaderCell::carried_record`), without moving the stage. +- `adopt` carries that record, the heard `S` and its ramp word, and remembers the successor's + prepared `S` (`adopted_sum`) until the first block. +- At the successor's first block the drain applies the record through `offsets_jump` from the + carried `S`, as the uninterrupted block does. A record in the successor's own cell (the join's + retarget, or a live write committed after the rebuild) replaces it unheard and counts one + superseded value. +- The join now delivers an `S` change as an offsets record. `join_level_records` returns + `LevelRecords`, and the carry retarget writes the offsets records to the successor's cells, + with the edit's length (`LevelRules::lengths`, the live path's rule). So a VCA move in the same + transaction as a structural edit ramps over its own length, not `J`. + +**Departure (Compare).** `Adoption::Compare` is no longer decided at `adopt`. With no record of +its own, the first block raises it when the `S` left after the carried record still differs +from the prepared one. Live plans always deliver a record, so this happens only on a plan with +no live cells: the join keeps an offsets-only change `Same` there, and the lane takes the +adoption rule over `J`. There a VCA edit's own length is not honoured. That is the one case +found, and no production host runs it (both hosts attach live cells). No watermark flag reports +it, because a plan with no live cells has no live values. A table change (`Reposition`) still +takes its adoption jump over `J`, even when the same transaction also moves `S` (D2). +`Adoption` is now ordered, and `adopt` keeps the stronger of its own adoption and one the +carried cell still owed (NIT 3). + +**MINOR 8.** The held-ramp re-report under `Reposition` is in `settle_adoption`, not D1.2, so it +survives U2 fold 2. It changes no sample: a held stage only remembers either operation, and the +obligation follows `free_from`. It is documented there as bookkeeping (M14 stays green). The +reposition's `seed_target` is gated. + +**Gates (all pass; each red under its mutation, checked once).** In +`crates/capi/src/runtime/live_tests.rs` (`set_control::automated_carry`, not `canonical_live`): + +- `vca_moves_pending_at_a_swap_keep_their_length_and_their_count` (R1c, R9, R10; a moving ride + and a flat ride; own 4,000 samples, the session's length, two moves, the rebuild committed + first): bits equal to the uninterrupted engine and `LIVE_VALUES_SUPERSEDED` equal. It is red + with no carry read, with the carry read uncounted, and with the heard `S` not carried. +- `a_vca_move_inside_a_rebuild_keeps_its_own_length` (reviewer C's case with a VCA move, 4,096 + samples and the session's length, both rides): red with no join offsets write, and red with no + heard `S`. +- `a_heard_vca_move_then_a_rebuild_continues_the_ride` (R1, mid-jump and settled). +- `an_offset_only_upsert_inside_a_rebuild_renders_as_it_does_alone` (R4). +- `a_held_ramp_obligation_owed_at_a_swap_is_carried` (R6, R7): red with the obligation not + carried (M15). +- `a_gained_ride_on_a_moving_fader_lets_the_fader_finish_its_ramp` (MINOR 8): red with no + `seed_target` (M13). + +In `crates/host-core/tests/stored_fader_automation.rs`: + +- `a_carried_lane_whose_s_moves_with_no_record_takes_the_adoption_rule` (the `Compare` path on + plans with no live cells): red with the raise removed (M1). + +**Not done here.** R2b and reviewer C's hand-move form need #1650b's hand offset, which is not on +this branch. The VCA forms above exercise the same records. R4 is not a discriminating test on +this branch, because the offset is not yet heard (#1650a). Its red check belongs to the merged +tree. + +**Numbers.** `lane_slot_bytes` 224 -> 304 (the carried record and the adopted `S`, both +channels), measured by `fader_automation_sizes()`. No byte gate moved. `realtime-policy`: ok, 160 +regions in 33 files. `audit capi`: `pcm_digest` `bffc21e857b5e134`, 0 violations, 0 allocations. +No edit under `crates/engine/src/realtime/`. diff --git a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md index bcdbb762a..d2ac9579b 100644 --- a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md @@ -413,3 +413,35 @@ the predecessor's heard `S`, against the successor's curve composed with its see `offsets_jump`'s shape. **For #1650b.** A hand move on an automated fader changes `S` through `EffectiveStrip::offsets_sum`; `level_records_between` then writes the offsets cell with no other change (the `Fader` row length via `LevelRules::lengths`). + +## U3 fold (batch 17) + +Fold of the U3 review, part U3a, on `codex/controls-batch-17k` over `86271e096`, uncommitted for +the coordinator. **Attempt count:** #1649 is at attempt 2 of 5 with this fold. + +- **MAJOR 1** (the offsets cell across a swap): see #1648's "U3 fold (batch 17)". The carry drain + reads the offsets cells (`read_counted`, `ALL`). An `S` change in a structural transaction + reaches the successor's offsets cell as the join's record with the edit's length. +- **MAJOR 3** (the ramp word): `a_flat_ride_under_a_vca_move_ramps_as_a_static_fader` + (`crates/capi/src/runtime/live_tests.rs`). A flat step ride at -7.25 dB under `v` and a + static -7.25 dB fader under `v` render equal bits on every block from a move of `v`, with the + session's length and with 256 samples. It is red with the ramp word written as 0 (M3a). +- **MAJOR 4** (a second move): `a_second_vca_move_over_an_automated_member_is_heard` (same file, + `automated_vca`). Moves to -6 dB and then -2 dB each equal a plan prepared from the committed + session after the jump. Gate 6 + (`crates/control-plane/tests/vca_automation_realtime.rs`) now also ends its script with `v` at + 0 dB and asserts the last block equals an unedited rig's. Both are red when render ignores + every write after the first (M8). +- **MINOR 7**: `the_scalar_oracle_renders_the_banks_fader_events` + (`crates/host-core/tests/stored_fader_automation.rs`) compares "the offsets writes are heard" + with the same live rig without the offsets writes (`render_parity(.., offsets)`). It is red + when the offsets reads are dropped on both owners. +- **NIT 6**: gate 2 (`many_vca_moves_before_one_render_are_the_last_alike_on_both_hosts`, + `hosts/host-web/tests/control_parity.rs`) takes a second burst of ten moves. After each + burst's jump it is held to the committed session's boot, not only to the twin (which misses + the same writes under M8). It is red under M8. Its unproven "a slot mixes two writes" claim is + removed from the test value. +- Checks: `builtins-compiler --features test-support`, `host-core --all-features`, + `control-plane --all-features`, `capi`, `host-web --lib --test control_parity` all pass. + `realtime-policy` 160 regions. `audit capi` `bffc21e857b5e134`, 0 violations. `lane_slot_bytes` + 224 -> 304. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 551acbb7a..4cb3c66f9 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -771,18 +771,32 @@ struct AutomatedFaderCell { obligation: HeldRampObligation, /// What the cell's first block after a plan swap that carried its lane owes (#1648 D2). adoption: Adoption, + /// The newest offsets record the plan-swap carry drain read from this cell and no block has + /// applied yet (#1648 D2, U3 fold): the successor's adopting cell takes it, and its first + /// block applies it through [`Self::offsets_jump`] as the uninterrupted block would. + carried_record: Option, + /// At an adopted cell until its first block's offsets reads: the `S` this plan was prepared + /// with. The adopted cell holds the carried `S` (the one its stage heard), and the carried and + /// the successor's own records move it. With no record of the cell's own (which is newer than + /// the preparation), an `S` left unequal to this one takes the adoption rule + /// ([`Adoption::Compare`]). + adopted_sum: Option, } /// What an automated channel's first block after a plan swap owes (#1648 D2). Set by -/// [`LaneFaderAutomation::adopt`] at the swap block, taken by the lane's next block, which knows -/// the node time. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// [`LaneFaderAutomation::adopt`] at the swap block (and raised to `Compare` by the first block's +/// offsets reads), taken by the lane's next block, which knows the node time. Ordered by strength: +/// two adoptions owed at once keep the stronger one. +#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)] enum Adoption { /// Nothing: the cell has rendered a block since it was prepared or adopted, or it continues /// its carried state verbatim (the same table, `a(n)` and `S`: the uninterrupted events). None, - /// The cell continues its carried table and cursor, but its `S` changed: the adoption rule - /// alone (the jump when the composed gain differs from the stage's). + /// The cell continues its carried table and cursor, but after the first block's offsets + /// records (the carried one and the successor's own) its `S` still differs from the one this + /// plan was prepared with: the adoption rule alone (the jump when the composed gain differs + /// from the stage's). Reached when no record could bring the change: a plan with no live + /// cells, whose join cannot write one. Compare, /// The cell's table is not the one its state was built on (a changed or gained curve, or a /// moved `a(n)`): it repositions its cursor in its own table, takes the stage's held ramp @@ -855,6 +869,8 @@ impl LaneFaderAutomation { offsets_ramp: cell.offsets_ramp, obligation: HeldRampObligation::default(), adoption: Adoption::None, + carried_record: None, + adopted_sum: None, } }) }), @@ -868,15 +884,19 @@ impl LaneFaderAutomation { // REALTIME_POLICY_BEGIN /// #1648 D2: this lane's stage was carried from `predecessor`'s lane at the swap block (the /// predecessor lane's automation, `None` when it had none). Each automated channel takes the - /// carried cell's event state and held-ramp obligation by its stable address (the strip, row 5 - /// and the channel; the strip move paired the strip) and owes its first block an adoption: - /// none when its table continues bit for bit (`continued_cells`, from the join) at the same - /// `a(n)` and `S`, the adoption rule alone when only `S` moved, and a reposition otherwise. A - /// channel the predecessor did not automate (a gained cell) keeps its prepared state and - /// repositions. The lane is not fresh: its stage holds the carried gain, so no `Set` lands - /// (#1647 D2 is for a lane that starts at its prepared value). A channel the successor does - /// not automate is released by the join's retarget record, not here. Allocation-free: plain - /// copies. + /// carried cell's event state, held-ramp obligation, heard `S` with its ramp word, and the + /// offsets record the carry drain read and no block applied, by its stable address (the + /// strip, row 5 and the channel; the strip move paired the strip). Its first block applies + /// that record and the successor's own (the join's retarget, a later live write) from the + /// heard `S`, as the uninterrupted block would, and then compares the `S` it holds with this + /// plan's ([`AutomatedFaderCell::adopted_sum`]). It owes that block a reposition when its + /// table does not continue bit for bit (`continued_cells`, from the join) or `a(n)` moved, + /// else nothing; an adoption the carried cell still owed (two swaps with no block between) + /// is kept when it is the stronger. A channel the predecessor did not automate (a gained + /// cell) keeps its prepared state and `S` and repositions. The lane is not fresh: its stage + /// holds the carried gain, so no `Set` lands (#1647 D2 is for a lane that starts at its + /// prepared value). A channel the successor does not automate is released by the join's + /// retarget record, not here. Allocation-free: plain copies. fn adopt(&mut self, predecessor: Option<&Self>, continued_cells: u8) { self.fresh = false; let arrival = self.arrival; @@ -895,19 +915,51 @@ impl LaneFaderAutomation { Some((carried, carried_arrival)) => { cell.state = carried.state; cell.obligation = carried.obligation; - if continued_cells & (1 << channel) == 0 || carried_arrival != arrival { + cell.adopted_sum = Some(cell.offsets_sum); + cell.offsets_sum = carried.offsets_sum; + cell.offsets_ramp = carried.offsets_ramp; + cell.carried_record = carried.carried_record; + let own = if continued_cells & (1 << channel) == 0 || carried_arrival != arrival + { Adoption::Reposition - } else if carried.offsets_sum.to_bits() == cell.offsets_sum.to_bits() { - Adoption::None } else { - Adoption::Compare - } + Adoption::None + }; + own.max(carried.adoption) } None => Adoption::Reposition, }; } } + /// #1648 D2 (U3 fold), the plan-swap carry drain: each automated channel whose offsets cell + /// holds a write takes the newest with every revision ([`LiveSnapshot::ALL`]), as the block + /// drain would at the next block, and keeps it for the successor's first block + /// ([`AutomatedFaderCell::carried_record`]); the stage is not moved here, because the jump's + /// target is the curve at the node time of that block. The read adds the writes it replaced + /// to `superseded`, and a kept record it replaces unheard (two carries with no block between) + /// adds one. Allocation-, lock- and syscall-free; one read per automated channel. + fn carry_offsets(&mut self, superseded: &LiveValuesSuperseded) { + for cell in self.channels.iter_mut().flatten() { + if cell.offsets_dirty.pending(&cell.offsets.dirty) == 0 { + continue; + } + if let CellRead::Applied { words, .. } = cell.offsets_reader.read_counted( + &cell.offsets.cell, + LiveSnapshot::ALL, + &mut cell.offsets_dirty, + 0, + superseded, + ) && cell + .carried_record + .replace(FaderOffsetsRecord::from_words(words)) + .is_some() + { + superseded.add_carried(1); + } + } + } + /// Take the block's node-time mapping and the fader row's jump length (#1647a, R17-3): the /// one path of both fader owners, the bank per lane and the scalar oracle per strip. fn take_block_time(&mut self, time: &GraphBlockTime<'_>, fader_jump: Option) { @@ -919,12 +971,20 @@ impl LaneFaderAutomation { /// offsets cell holds a newer write takes its `S` and ramp word, and the lane jumps to the /// composed gain at the jump's completion when that gain differs in bits from the lane's /// current gain target ([`AutomatedFaderCell::offsets_jump`]). An applied read adds the - /// writes it replaced to `superseded`. Allocation-, lock- and syscall-free; one read per - /// automated channel. + /// writes it replaced to `superseded`; `superseded` is `None` on a lane with no control lane, + /// whose cells have no writer and are not read. + /// + /// #1648 D2 (U3 fold), at an adopted cell's first block: the record the carry drain kept is + /// applied the same way, from the carried `S` the stage heard, unless the cell's own read + /// applied a newer one, which replaces it unheard (one superseded value, as two writes before + /// one uninterrupted block). With no record of its own, an `S` still unequal to the one this + /// plan was prepared with takes it and owes the adoption rule ([`Adoption::Compare`]); a + /// record of its own is newer than the preparation and is the committed `S`. Allocation-, lock- and + /// syscall-free; at most one read per automated channel. fn read_offsets( &mut self, snapshot: LiveSnapshot, - superseded: &LiveValuesSuperseded, + superseded: Option<&LiveValuesSuperseded>, stage: &mut impl FaderJumpStage, ) { let Self { @@ -937,26 +997,45 @@ impl LaneFaderAutomation { let Some(cell) = cell.as_mut() else { continue; }; - if cell.offsets_dirty.pending(&cell.offsets.dirty) == 0 { - continue; + let carried = cell.carried_record.take(); + let own = superseded.and_then(|superseded| { + if cell.offsets_dirty.pending(&cell.offsets.dirty) == 0 { + return None; + } + let CellRead::Applied { words, .. } = cell.offsets_reader.read_counted( + &cell.offsets.cell, + snapshot, + &mut cell.offsets_dirty, + 0, + superseded, + ) else { + return None; + }; + if carried.is_some() { + superseded.add_carried(1); + } + Some(FaderOffsetsRecord::from_words(words)) + }); + let adopted_sum = cell.adopted_sum.take(); + if let Some(record) = own.or(carried) { + let jumped = cell.offsets_jump(program, *span, channel, record, stage); + // Unreachable refusal: the cell index is the program's own (prepared from it), + // the curve is in the fader domain and `vca_compose_db` clamps (a NaN `S`, which + // a validated record never carries, fails `checked_fader_gain`, so no NaN reaches + // the stage), and the lane is a populated member. In release a refusal leaves the + // stage's gain as it was: a hold, no panic. + debug_assert!(jumped, "an offsets jump is refused at render"); + } + // A record of the cell's own is newer than this plan's preparation (the join's + // retarget, or a live write committed after it), so its `S` is the committed one; a + // carried record is older than the preparation, whose `S` it must then match. + if own.is_none() + && let Some(sum) = adopted_sum + && sum.to_bits() != cell.offsets_sum.to_bits() + { + cell.offsets_sum = sum; + cell.adoption = cell.adoption.max(Adoption::Compare); } - let CellRead::Applied { words, .. } = cell.offsets_reader.read_counted( - &cell.offsets.cell, - snapshot, - &mut cell.offsets_dirty, - 0, - superseded, - ) else { - continue; - }; - let record = FaderOffsetsRecord::from_words(words); - let jumped = cell.offsets_jump(program, *span, channel, record, stage); - // Unreachable refusal: the cell index is the program's own (prepared from it), the - // curve is in the fader domain and `vca_compose_db` clamps (a NaN `S`, which a - // validated record never carries, fails `checked_fader_gain`, so no NaN reaches the - // stage), and the lane is a populated member. In release a refusal leaves the stage's - // gain as it was: a hold, no panic. - debug_assert!(jumped, "an offsets jump is refused at render"); } } // REALTIME_POLICY_END @@ -1553,7 +1632,12 @@ struct AdoptionBlock<'p, 'a> { /// /// A [`Adoption::Reposition`] places the cursor at the block's node time in the cell's own table /// (#1637 D4) and reports the stage's held ramp again (batch 13b: `reposition` drops the one the -/// carried state held). Then, for a reposition or [`Adoption::Compare`], the adoption rule: when +/// carried state held). That report changes no sample: a held stage only remembers a gain +/// operation of either kind, and the obligation is decided by where the gain is free, not by the +/// event's flag. It keeps the repositioned cell's events what the cell gives under a hold (held +/// events as `RememberGain`, as the test-only event probe reports them); batch 17 U3 fold, +/// MINOR 8: kept as bookkeeping, with no audible gate. +/// Then, for a reposition or [`Adoption::Compare`], the adoption rule: when /// the curve at the block's first sample, composed with this plan's `S` /// (`checked_fader_gain(vca_compose_db(curve, S))`, #1648 departure 4), differs in bits from the /// gain the stage aims for, the block opens with a jump (returns `true`). Otherwise a repositioned @@ -3024,9 +3108,27 @@ impl FaderBankProcessor { /// it on a quiescent predecessor, for the lanes it exports (#1277); no production render path /// takes `ALL`. // REALTIME_POLICY_BEGIN + /// + /// Each carried automated lane's offsets cells are read the same way (#1648 D2, U3 fold): + /// the newest record is kept for the successor's first block, which applies it + /// ([`LaneFaderAutomation::carry_offsets`]). fn apply_pending(&mut self, lanes: u8) -> Result<(), RenderError> { - let Self { bank, controls, .. } = self; - drain_fader_controls(bank, controls, LiveSnapshot::ALL, lanes) + let Self { + bank, + controls, + automation, + .. + } = self; + let result = drain_fader_controls(bank, controls, LiveSnapshot::ALL, lanes); + for (lane, slot) in automated_lanes(automation) { + if !lane_selected(lanes, lane) { + continue; + } + if let Some(Some(reader)) = controls.get(lane) { + slot.carry_offsets(&reader.superseded); + } + } + result } /// Whether `predecessor` is a fader bank of this bank's width with lane `predecessor_lane` @@ -3251,9 +3353,9 @@ fn bank_hold(bank: &BuiltinFaderBank) -> impl Fn(usize, usize) -> Option], @@ -3261,12 +3363,13 @@ fn read_fader_offsets( snapshot: LiveSnapshot, ) { for (lane, slot) in automated_lanes(automation) { - let Some(Some(reader)) = controls.get(lane) else { - continue; - }; + let superseded = controls + .get(lane) + .and_then(Option::as_ref) + .map(|reader| &reader.superseded); slot.read_offsets( snapshot, - &reader.superseded, + superseded, &mut BankLaneStage { bank: &mut *bank, lane, @@ -7607,7 +7710,7 @@ impl LiveControlFaderProcessor { .map_err(render_error); if let Some(automation) = self.automation.as_deref_mut() { // #1649 D3: the offsets cells, after the drain, as the fader bank reads them. - automation.read_offsets(snapshot, &self.control.superseded, &mut self.fader); + automation.read_offsets(snapshot, Some(&self.control.superseded), &mut self.fader); } result } diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index df10559cb..091332d91 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -8490,6 +8490,518 @@ mod set_control { assert_eq!(offsets, [Some(1), Some(1)], "{label}: eq0's offsets cells"); } } + + /// #1649 (U3 fold, MAJOR 4). Two moves of `v` in turn on a playing engine: -6 dB, then, + /// after the first has settled, -2 dB. Each is `live`, and from the end of its jump and + /// the first grid ramp after it the output equals a plan prepared from the committed + /// session, so the automated member hears the second write as it heard the first. + /// + /// Test value: red if render applies only the first write of an offsets cell (a writer + /// whose sequence restarts at each write is acked and never heard), which a single move + /// cannot show. + #[test] + fn a_second_vca_move_over_an_automated_member_is_heard() { + let before = document(|_| {}); + let fader = fader_length(&before); + let mut rig = Rig::new(&before); + for _ in 0..4 { + rig.step(); + } + let k = window(rig.latency(), fader + 128, rig.quantum); + for db in [-6.0, -2.0] { + let bytes = + rig.transaction(&[set(vca_row("v", VCA_OFFSET_DB, Lane::Both), db, None)]); + let (result, response) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK, "v at {db} dB"); + assert_eq!(edit_path_of(&response), protocol::EditPath::Live); + let committed = rig.snapshot(); + let edit_block = rig.block; + let live: Vec> = (0..k + 4).map(|_| rig.step()).collect(); + let reference = Reference::run(&committed, rig.block); + for (offset, pcm) in live.iter().enumerate().skip(k as usize) { + let block = edit_block as usize + offset; + assert_eq!( + bits(pcm), + bits(&reference[block]), + "v at {db} dB: block {block} renders the committed session's plan" + ); + } + } + } + } + + /// #1648 D2 and #1649 across a plan swap (batch 17 U3 fold): a level value that reaches an + /// automated fader lane around a rebuild is heard as the uninterrupted engine hears it. Each + /// test renders rigs of one document in lockstep: `a` takes a rebuild (a muted track added, + /// whose strip renders exact zeros), `u` the same live edits with no rebuild, and `plain` + /// none of the edits, to show that they are heard. + mod automated_carry { + use super::*; + + /// A fader entry of track `track` (both lanes) with `segments`. + fn entry(track: &str, segments: Vec) -> session::Automation { + session::Automation { + id: id("ride"), + target: session::ControlAddress { + owner: session::Owner::Strip(id(track)), + section: session::Section::Builtins, + control: FADER_DB, + lane: session::ParameterChannel::Both, + }, + segments, + offset: session::AutomationOffset::default(), + } + } + + /// One segment of `shape` over `[start, end)`, from `from` to `to` dB. + fn segment( + shape: session::AutomationShape, + (start, end): (u64, u64), + (from, to): (f32, f32), + ) -> session::AutomationSegment { + session::AutomationSegment { + shape, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: session::ParameterUnit::Db, + } + } + + /// The stateless session (no effect, no input filter) with a long source and `change` + /// applied, given the first two track IDs. + fn session_with(change: impl FnOnce(&mut SessionModel, &str, &str)) -> String { + let mut model = stateless_session(48_000); + model.sources[0].frames = SOURCE_FRAMES; + let first = model.tracks[0].id.as_str().to_owned(); + let second = model.tracks[1].id.as_str().to_owned(); + change(&mut model, &first, &second); + session::canonical_session_json(&model).expect("canonical session") + } + + /// The first track rides its fader linearly from 0 dB to -12 dB over the source, so every + /// grid point moves the lane, and VCA `v` holds the first two tracks (the second has no + /// automation). + fn riding() -> String { + session_with(|model, first, second| { + model.automation.push(entry( + first, + vec![segment( + session::AutomationShape::Linear, + (0, SOURCE_FRAMES), + (0.0, -12.0), + )], + )); + model.vcas.push(vca_of("v", &[first, second])); + }) + } + + /// [`riding`] with a flat ride at -3 dB: an offsets jump then moves the lane only because + /// `S` moved, so the `S` it jumps from decides whether it jumps at all. + fn flat() -> String { + session_with(|model, first, second| { + model.automation.push(entry( + first, + vec![segment( + session::AutomationShape::Step, + (0, SOURCE_FRAMES), + (-3.0, -3.0), + )], + )); + model.vcas.push(vca_of("v", &[first, second])); + }) + } + + /// The first track of `document`. + fn first_track(document: &str) -> String { + parse_session_json(document).expect("model").tracks[0] + .id + .as_str() + .to_owned() + } + + /// A move of `v` to `db` over `ramp`. + fn vca_move(db: f32, ramp: Option) -> SessionEdit { + set(vca_row("v", VCA_OFFSET_DB, Lane::Both), db, ramp) + } + + /// Send `edits` on `rig`; the reply's path must be `path`. + fn send(rig: &mut Rig, edits: &[SessionEdit], path: protocol::EditPath, label: &str) { + let bytes = rig.transaction(edits); + let (result, response) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK, "{label}"); + assert_eq!(edit_path_of(&response), path, "{label}: path"); + } + + /// Three rigs of `document`, four blocks rendered on each. + fn warm(document: &str) -> [Rig; 3] { + let mut rigs = [0, 1, 2].map(|_| Rig::new(document)); + for _ in 0..4 { + let [a, u, plain] = &mut rigs; + let x = bits(&a.step()); + assert_eq!(x, bits(&u.step()), "warm-up"); + assert_eq!(x, bits(&plain.step()), "warm-up"); + } + rigs + } + + /// Render `blocks` blocks on the three rigs. Returns the first block of `a` whose bits + /// differ from `u`'s, with its first differing sample, and whether `u` differs from + /// `plain` in any block. Every block of `a` is allocation-free. + fn lockstep(rigs: &mut [Rig; 3], blocks: usize) -> (Option<(u64, usize)>, bool) { + let [a, u, plain] = rigs; + let (mut first, mut heard) = (None, false); + for _ in 0..blocks { + let block = a.block; + let (x, used) = a.step_counted(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "block {block} allocated or freed" + ); + let (x, y) = (bits(&x), bits(&u.step())); + heard |= y != bits(&plain.step()); + if x != y && first.is_none() { + first = Some((block, x.iter().zip(&y).position(|(x, y)| x != y).unwrap())); + } + } + (first, heard) + } + + /// The superseded-value counter of `rig`. + fn superseded(rig: &mut Rig) -> u64 { + rig.counter(protocol::CounterId::LiveValuesSuperseded) + } + + /// U3 fold, MAJOR 1 (R1c, R9, R10). Live VCA moves whose offsets writes are still unread + /// when a rebuild commits (no block between them) are carried into the successor: the + /// carry drain reads them, and the successor's first block jumps from the `S` the stage + /// heard over the edit's own length, as the uninterrupted engine does. Cases: one move + /// with its own 4,000 samples, one with the session's length, two moves (the first is + /// superseded unheard), and a rebuild committed first with the move written to the + /// pending successor. Every block equals the uninterrupted engine's, the moves are + /// heard, the successor is adopted, and `LIVE_VALUES_SUPERSEDED` holds the same count. + /// + /// Test value: red if the carry drain does not read the offsets cells (the successor + /// starts from the committed `S` and jumps over the fader length, and a superseded write + /// goes uncounted), or if the carried record or the heard `S` does not reach the + /// successor's cell. + #[test] + fn vca_moves_pending_at_a_swap_keep_their_length_and_their_count() { + bench_support::alloc::assert_installed(); + for (ride, document) in [("moving ride", riding()), ("flat ride", flat())] { + pending_case(ride, &document); + } + } + + /// One document of [`vca_moves_pending_at_a_swap_keep_their_length_and_their_count`]. + fn pending_case(ride: &str, document: &str) { + let model = parse_session_json(document).expect("model"); + let rebuild = add_muted_track(&model, "added"); + let cases: [(&str, Vec, bool); 4] = [ + ( + "its own 4,000 samples", + vec![vca_move(-6.0, Some(4_000))], + false, + ), + ("the session's length", vec![vca_move(-6.0, None)], false), + ( + "two moves", + vec![vca_move(-6.0, None), vca_move(-3.0, Some(4_000))], + false, + ), + ( + "the rebuild committed first", + vec![vca_move(-6.0, Some(4_000))], + true, + ), + ]; + for (label, moves, rebuild_first) in cases { + let label = &format!("{ride}, {label}"); + let mut rigs = warm(document); + if rebuild_first { + send(&mut rigs[0], &rebuild, protocol::EditPath::Rebuild, label); + } + for edit in &moves { + for rig in &mut rigs[..2] { + send( + rig, + core::slice::from_ref(edit), + protocol::EditPath::Live, + label, + ); + } + } + if !rebuild_first { + send(&mut rigs[0], &rebuild, protocol::EditPath::Rebuild, label); + } + let (first, heard) = lockstep(&mut rigs, 44); + assert_eq!(rigs[0].summary().3, 0, "{label}: the successor is adopted"); + assert_eq!(first, None, "{label}: first difference (block, sample)"); + assert!(heard, "{label}: the moves are heard"); + let counts = [superseded(&mut rigs[0]), superseded(&mut rigs[1])]; + assert_eq!( + counts[0], counts[1], + "{label}: LIVE_VALUES_SUPERSEDED [a, u]" + ); + } + } + + /// U3 fold, MAJOR 1 (reviewer C's case). A VCA move in the same transaction as a + /// structural edit (the transaction rebuilds) reaches the carried automated lane as the + /// join's offsets record, with the edit's own length: every block equals the same move + /// sent alone (`live`), with its own 4,096 samples and with the session's length. + /// + /// Test value: red if the join writes no offsets record (the successor's `S` reaches the + /// lane by the adoption rule, over the fader length, from the block's curve), or the + /// carried lane composes its first block with the new `S` as if it had heard it. + #[test] + fn a_vca_move_inside_a_rebuild_keeps_its_own_length() { + bench_support::alloc::assert_installed(); + for ((ride, document), (length, ramp)) in + [("moving ride", riding()), ("flat ride", flat())] + .into_iter() + .flat_map(|document| { + [ + ("its own 4,096 samples", Some(4_096)), + ("the session's length", None), + ] + .map(|length| (document.clone(), length)) + }) + { + let label = &format!("{ride}, {length}"); + let model = parse_session_json(&document).expect("model"); + let mut rigs = warm(&document); + let mut edits = vec![vca_move(-6.0, ramp)]; + edits.extend(add_muted_track(&model, "added")); + send(&mut rigs[0], &edits, protocol::EditPath::Rebuild, label); + send( + &mut rigs[1], + &[vca_move(-6.0, ramp)], + protocol::EditPath::Live, + label, + ); + let (first, heard) = lockstep(&mut rigs, 44); + assert_eq!(rigs[0].summary().3, 0, "{label}: the successor is adopted"); + assert_eq!(first, None, "{label}: first difference (block, sample)"); + assert!(heard, "{label}: the move is heard"); + } + } + + /// U3 fold, MAJOR 2 (R1). A live VCA move heard (mid-jump after three blocks, settled + /// after twelve), then a rebuild that keeps the ride: the carried lane continues with no + /// adoption jump, and every block equals the uninterrupted engine's. + /// + /// Test value: red if adoption compares the successor's `S` with anything but the `S` + /// the stage heard (a stale `S` makes a jump the uninterrupted engine never makes), or + /// the carried state does not continue. + #[test] + fn a_heard_vca_move_then_a_rebuild_continues_the_ride() { + bench_support::alloc::assert_installed(); + let document = riding(); + let model = parse_session_json(&document).expect("model"); + for heard_blocks in [3, 12] { + let label = format!("rebuilt after {heard_blocks} blocks"); + let mut rigs = warm(&document); + for rig in &mut rigs[..2] { + send( + rig, + &[vca_move(-6.0, None)], + protocol::EditPath::Live, + &label, + ); + } + let (first, _) = lockstep(&mut rigs, heard_blocks); + assert_eq!(first, None, "{label}: before the rebuild"); + send( + &mut rigs[0], + &add_muted_track(&model, "added"), + protocol::EditPath::Rebuild, + &label, + ); + let (first, heard) = lockstep(&mut rigs, 30); + assert_eq!(rigs[0].summary().3, 0, "{label}: the successor is adopted"); + assert_eq!(first, None, "{label}: first difference (block, sample)"); + assert!(heard, "{label}: the move is heard"); + } + } + + /// U3 fold, MAJOR 2 (R4). An offset-only `UpsertAutomation` of the ride (a hand move's + /// stored offset, #1650) inside a structural transaction renders as the same upsert sent + /// alone. On this branch the offset is stored and not yet heard (#1650a); with #1650b it + /// moves `S` and reaches the carried lane as the join's offsets record. + /// + /// Test value: red if an offset carried across a swap is heard differently from the same + /// offset committed without one (with #1650b: a lost record, or a jump over another + /// length). + #[test] + fn an_offset_only_upsert_inside_a_rebuild_renders_as_it_does_alone() { + let document = riding(); + let model = parse_session_json(&document).expect("model"); + let mut ride = model.automation[0].clone(); + ride.offset = session::AutomationOffset { + left_db: -4.5, + right_db: -4.5, + }; + let upsert = SessionEdit::UpsertAutomation { automation: ride }; + let mut rigs = warm(&document); + let mut edits = vec![upsert.clone()]; + edits.extend(add_muted_track(&model, "added")); + send(&mut rigs[0], &edits, protocol::EditPath::Rebuild, "R4"); + let bytes = rigs[1].transaction(&[upsert]); + assert_eq!(rigs[1].send(&bytes).0, crate::RESULT_OK, "R4: alone"); + let (first, _) = lockstep(&mut rigs, 40); + assert_eq!(rigs[0].summary().3, 0, "R4: the successor is adopted"); + assert_eq!(first, None, "R4: first difference (block, sample)"); + } + + /// U3 fold, MAJOR 2 (R6, R7). The batch 13b held-ramp obligation owed at a swap: the + /// muted first track steps from 0 dB to -9 dB at sample 3,000 inside an unmute ramp, so + /// the step is only remembered and the lane owes a move where the ramp ends. A rebuild + /// lands while the obligation is owed: inside an 8,000-sample ramp, and when a + /// 3,072-sample ramp ends on the last sample before the swap (the move is due at the + /// swap block's first sample). Every block equals the uninterrupted engine's. + /// + /// Test value: red if the obligation (owed, due at entry) is not carried across the swap: + /// the successor's lane then stays at the gain the unmute aimed for. + #[test] + fn a_held_ramp_obligation_owed_at_a_swap_is_carried() { + bench_support::alloc::assert_installed(); + let document = session_with(|model, first, _| { + model.tracks[0].fader.left_mute = true; + model.tracks[0].fader.right_mute = true; + let step = |(start, end), db| { + segment(session::AutomationShape::Step, (start, end), (db, db)) + }; + model.automation.push(entry( + first, + vec![step((0, 3_000), 0.0), step((3_000, SOURCE_FRAMES), -9.0)], + )); + }); + let model = parse_session_json(&document).expect("model"); + let track = first_track(&document); + let quantum = Rig::new(&document).quantum; + // The unmute starts at block 4's first sample. + for (label, ramp, swap) in [ + ("owed inside the ramp", 8_000, 26_u64), + ( + "due at the swap's first sample", + (28 - 4) * quantum as u32, + 28, + ), + ] { + let mut rigs = warm(&document); + let unmute = [set(builtin(&track, MUTE, Lane::Both), 0.0, Some(ramp))]; + for rig in &mut rigs[..2] { + send(rig, &unmute, protocol::EditPath::Live, label); + } + let (first, _) = lockstep(&mut rigs, (swap - 4) as usize); + assert_eq!(first, None, "{label}: before the swap"); + send( + &mut rigs[0], + &add_muted_track(&model, "added"), + protocol::EditPath::Rebuild, + label, + ); + let (first, heard) = lockstep(&mut rigs, 60); + assert_eq!(rigs[0].summary().3, 0, "{label}: the successor is adopted"); + assert_eq!(first, None, "{label}: first difference (block, sample)"); + assert!(heard, "{label}: the unmute is heard"); + } + } + + /// U3 fold, MINOR 8 (#1648 D2, the reposition's seeding). A live fader move to -6 dB + /// (the session's 960 samples) and a rebuild that adds a ride on the same track commit in + /// one gap between blocks. The ride holds 0 dB until sample 100 and -6 dB after, so the + /// gained cell is seeded with 0 dB while its curve at the swap equals the gain the stage + /// is ramping to: the cell takes that value as its target and the stage finishes its own + /// ramp, as the plain fader does with no ride. Every block equals the rig that only moved + /// the fader. + /// + /// Test value: red if a repositioned cell whose curve equals the stage's target keeps its + /// seeded target (its first grid event would retarget the stage over 64 samples and cut + /// the fader's ramp short). + #[test] + fn a_gained_ride_on_a_moving_fader_lets_the_fader_finish_its_ramp() { + bench_support::alloc::assert_installed(); + let document = session_with(|_, _, _| {}); + let mut model = parse_session_json(&document).expect("model"); + let track = first_track(&document); + let step = + |(start, end), db| segment(session::AutomationShape::Step, (start, end), (db, db)); + model.automation.push(entry( + &track, + vec![step((0, 100), 0.0), step((100, SOURCE_FRAMES), -6.0)], + )); + let ride = SessionEdit::UpsertAutomation { + automation: model.automation[0].clone(), + }; + let mut rigs = warm(&document); + let fader = [ + set(builtin(&track, FADER_DB, Lane::Left), -6.0, None), + set(builtin(&track, FADER_DB, Lane::Right), -6.0, None), + ]; + for rig in &mut rigs[..2] { + send(rig, &fader, protocol::EditPath::Live, "the fader move"); + } + send( + &mut rigs[0], + &[ride], + protocol::EditPath::Rebuild, + "the ride", + ); + let (first, heard) = lockstep(&mut rigs, 20); + assert_eq!(rigs[0].summary().3, 0, "the successor is adopted"); + assert_eq!(first, None, "first difference (block, sample)"); + assert!(heard, "the fader move is heard"); + } + + /// U3 fold, MAJOR 3 (#1649 D3's ramp word). Twin A holds a flat step ride at -7.25 dB + /// under VCA `v`; twin B holds a static -7.25 dB fader under `v`. They render the same + /// bits, and a move of `v` on both (with the session's length and with its own 256 + /// samples) ramps the automated lane exactly as the plain one: equal bits on every block + /// from the move on, with no settle window. + /// + /// Test value: red if the offsets cell's ramp word is lost or replaced (a `0` word sets + /// the gain at once, a click; another length ramps differently), which a comparison + /// after the jump cannot see. + #[test] + fn a_flat_ride_under_a_vca_move_ramps_as_a_static_fader() { + bench_support::alloc::assert_installed(); + let ride = session_with(|model, first, _| { + model.automation.push(entry( + first, + vec![segment( + session::AutomationShape::Step, + (0, SOURCE_FRAMES), + (-7.25, -7.25), + )], + )); + model.vcas.push(vca_of("v", &[first])); + }); + let fixed = session_with(|model, first, _| { + model.tracks[0].fader.left_db = -7.25; + model.tracks[0].fader.right_db = -7.25; + model.vcas.push(vca_of("v", &[first])); + }); + for (label, ramp) in [("the session's length", None), ("its own 256", Some(256))] { + let mut rigs = [Rig::new(&ride), Rig::new(&fixed), Rig::new(&fixed)]; + let (first, _) = lockstep(&mut rigs, 4); + assert_eq!(first, None, "{label}: before the move"); + for rig in &mut rigs[..2] { + send( + rig, + &[vca_move(-6.0, ramp)], + protocol::EditPath::Live, + label, + ); + } + let (first, heard) = lockstep(&mut rigs, 20); + assert_eq!(first, None, "{label}: first difference (block, sample)"); + assert!(heard, "{label}: the move is heard"); + } + } } } diff --git a/crates/control-plane/tests/vca_automation_realtime.rs b/crates/control-plane/tests/vca_automation_realtime.rs index 4669ed236..17402d74d 100644 --- a/crates/control-plane/tests/vca_automation_realtime.rs +++ b/crates/control-plane/tests/vca_automation_realtime.rs @@ -141,9 +141,27 @@ struct Rig { revision: u64, request: u64, block: u64, + /// The last block's samples, left plane then right. + last: Vec, } impl Rig { + /// A rig on [`document`], booted. + fn new() -> Self { + let (document, revision) = document(); + let CompiledChildren { session, plan } = + compile_children::(&document, limits(), Box::new(Preparer)) + .expect("boot compile"); + Self { + session, + plan, + revision, + request: 1, + block: 0, + last: vec![0.0; QUANTUM * 2], + } + } + /// Feed and render one block; the allocator events of the render call alone. fn block(&mut self) -> bench_support::alloc::Counters { let start = self.block * QUANTUM as u64; @@ -169,6 +187,7 @@ impl Rig { let mark = bench_support::alloc::current_thread_counters(); self.plan.render(start, output).expect("a rendered block"); let used = bench_support::alloc::current_thread_delta_since(mark); + self.last.copy_from_slice(&samples); self.block += 1; used } @@ -233,24 +252,22 @@ impl Rig { } } -/// Gate 6. Test value: red if the offsets cell's read, the jump or the composition with the new -/// `S` allocates or frees on render. +/// Gate 6. Every render block after the warm-up allocates and frees nothing. The script ends +/// with `v` back at 0 dB, so once the last jump and the first grid ramp after it have passed +/// (twelve blocks of 128 samples against a jump of 960), the output equals an unedited rig's. +/// +/// Test value: red if the offsets cell's read, the jump or the composition with the new `S` +/// allocates or frees on render, or if render hears only some of the writes of a cell (one that +/// ignores every write after the first is left at -6 dB). #[test] fn a_vca_move_over_an_automated_member_renders_allocation_free() { bench_support::alloc::assert_installed(); - let (document, revision) = document(); - let CompiledChildren { session, plan } = - compile_children::(&document, limits(), Box::new(Preparer)).expect("boot compile"); - let mut rig = Rig { - session, - plan, - revision, - request: 1, - block: 0, - }; + let mut rig = Rig::new(); + let mut plain = Rig::new(); // Warm-up: the process statics and the first blocks' one-time work. for _ in 0..4 { rig.block(); + plain.block(); } let script: [&[(f32, Option)]; 4] = [ &[(-6.0, None)], @@ -266,6 +283,7 @@ fn a_vca_move_over_an_automated_member_renders_allocation_free() { for _ in 0..12 { let block = rig.block; let used = rig.block(); + plain.block(); assert_eq!( (used.allocations, used.deallocations), (0, 0), @@ -273,4 +291,15 @@ fn a_vca_move_over_an_automated_member_renders_allocation_free() { ); } } + let bits = |samples: &[f32]| { + samples + .iter() + .map(|sample| sample.to_bits()) + .collect::>() + }; + assert_eq!( + bits(&rig.last), + bits(&plain.last), + "v back at 0 dB renders the unedited session" + ); } diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index cb579b7d4..07fb7a803 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -3665,6 +3665,17 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( for record in retarget.fader.iter().map_while(|record| *record) { producer.fader.write(stamp, record); } + // #1648 D2 (U3 fold): an automated lane's new `S`, with the edit's length, through + // the successor's own offsets cell, as the live path writes it. The record exists only + // for a lane the successor automates, whose cell a plan with live cells attaches. + for (channel, record) in retarget.offsets.iter().enumerate() { + if let Some(record) = *record { + if !producer.fader.has_offsets(channel) { + return Err(graph_failure("host.control.order")); + } + producer.fader.write_offsets(stamp, channel, record); + } + } if let Some(record) = retarget.matrix { producer.producer.write(stamp, record); } @@ -4598,23 +4609,8 @@ impl<'a> EffectiveDiff<'a> { level_differs(&self.before.strips[before], &self.after.strips[after]) } - /// The level records of one strip: its effective values in the two models, with the lengths - /// the ramp rules give. - /// - /// # Errors - /// - /// [`OutOfDomain`] as [`fader_records`]. - pub(crate) fn level_records( - &self, - before: usize, - after: usize, - ) -> Result<[Option; 4], OutOfDomain> { - self.strip_level_records(before, after) - .map(|records| records.fader) - } - - /// The level records of one strip with its offsets-cell writes (#1649 D4): the live path's - /// form of [`Self::level_records`]. + /// The level records of one strip, its effective values in the two models with the lengths + /// the ramp rules give, and its offsets-cell writes (#1649 D4). /// /// # Errors /// @@ -4634,7 +4630,9 @@ impl<'a> EffectiveDiff<'a> { } /// The carry retargets of one strip's fader/mute stage at a successor's strip join (#1277 D5): - /// its [`Self::level_records`], plus the release of each lane whose stored fader automation + /// its [`Self::strip_level_records`], the offsets-cell writes with them (#1648 D2, U3 fold: + /// an `S` the transaction moves reaches a carried automated lane as the live path's record, + /// with the edit's length), plus the release of each lane whose stored fader automation /// the change removes (#1648 D2, "a cell lost"). Such a lane's carried stage holds the curve's /// last target, which no model holds, so the static values of the two models cannot show the /// difference; a `FaderDb` of the lane's effective value in `next` moves it there over the @@ -4651,12 +4649,13 @@ impl<'a> EffectiveDiff<'a> { &self, before: usize, after: usize, - ) -> Result<[Option; 4], OutOfDomain> { - let mut records = self.level_records(before, after)?; + ) -> Result { + let mut level = self.strip_level_records(before, after)?; + let records = &mut level.fader; let change = self.change(before, after); let automated_before = controls::automated_fader_lanes(self.rules.current, change.id); if automated_before == [false; 2] { - return Ok(records); + return Ok(level); } let automated_after = controls::automated_fader_lanes(self.rules.next.structure(), change.id); @@ -4674,7 +4673,7 @@ impl<'a> EffectiveDiff<'a> { } } if released == [false; 2] { - return Ok(records); + return Ok(level); } let lengths = self.rules.lengths(&change); let db = self.after.strips[after].fader_db; @@ -4693,7 +4692,7 @@ impl<'a> EffectiveDiff<'a> { }); } } - Ok(records) + Ok(level) } #[cfg(feature = "control-provider")] @@ -5197,7 +5196,7 @@ impl FamilyJoin { /// One strip present in both models, with how its fader/mute and matrix families differ. struct StripJoin<'a> { id: &'a str, - fader: FamilyJoin<[Option; 4]>, + fader: FamilyJoin, matrix: FamilyJoin, /// The fader lanes whose stored automation table both models hold bit for bit (#1648 D2, /// [`controls::continued_fader_lanes`]), bit 0 left and bit 1 right: the fader move's @@ -5304,9 +5303,17 @@ fn join_strips<'a>( let matrix_record = matrix_record(before_matrix, strip.matrix_or_pan, ramps, edit); let fader_live = live && fader_records.is_ok(); let matrix_live = live && matrix_record.is_ok(); + // An offsets-cell write alone (an `S` the transaction moves on an automated lane) is a + // live difference only where the successor has cells to take it; on a plan with no live + // cells the carried lane takes the change by the adoption rule (#1648 D2), so the stage + // still carries as `Same`. let fader = match fader_records { Err(_) => FamilyJoin::Prepared, - Ok(records) if records[0].is_none() => FamilyJoin::Same, + Ok(records) + if records.fader[0].is_none() && (!fader_live || records.offsets == [None; 2]) => + { + FamilyJoin::Same + } Ok(_) if !fader_live => FamilyJoin::Prepared, Ok(records) => FamilyJoin::Live(records), }; @@ -5545,6 +5552,8 @@ fn resolve_route_carry<'a>( struct StripRetarget<'a> { id: &'a str, fader: [Option; 4], + /// The automated lanes' offsets-cell writes, `[left, right]` (#1648 D2, U3 fold). + offsets: [Option; 2], matrix: Option, } @@ -5604,18 +5613,19 @@ fn resolve_strip_carry<'a>( if !(carries[0] && carries[1]) { carry.restarted.push(join.id.into()); } - let fader = match join.fader { + let LevelRecords { fader, offsets } = match join.fader { FamilyJoin::Live(records) if carries[0] => records, - _ => [None; 4], + _ => LevelRecords::default(), }; let matrix = match join.matrix { FamilyJoin::Live(record) if carries[1] => Some(record), _ => None, }; - if fader[0].is_some() || matrix.is_some() { + if fader[0].is_some() || offsets != [None; 2] || matrix.is_some() { carry.retargets.push(StripRetarget { id: join.id, fader, + offsets, matrix, }); } diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index c7bfa4342..5662e0c02 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -1152,9 +1152,10 @@ fn parity_model() -> SessionModel { /// 1's left lane at block 10 and an unmute ramp at block 20, so the lane holds its gain. /// With handles, the offsets cells take two writes too (#1649 D3): track 0's two lanes a new `S` /// at block 15 (a jump of 480 samples), and track 1's left lane one at block 20, in the block -/// whose drain starts the unmute ramp, so its jump meets a held gain. +/// whose drain starts the unmute ramp, so its jump meets a held gain. With `offsets` false the +/// offsets cells take no write and the mute ramps run as they do with it. #[cfg(feature = "test-support")] -fn render_parity(mut rig: Rig, blocks: usize) -> [Vec; 2] { +fn render_parity(mut rig: Rig, blocks: usize, offsets: bool) -> [Vec; 2] { use builtins_compiler::TestOnlyWriteGate; let mut gate = rig.handles.is_some().then(|| { rig.prepared @@ -1175,7 +1176,7 @@ fn render_parity(mut rig: Rig, blocks: usize) -> [Vec; 2] { smoothing_samples: 300, }, ); - if block == 20 { + if block == 20 && offsets { handles.strip_controls[1].fader.write_offsets( stamp, 0, @@ -1189,6 +1190,7 @@ fn render_parity(mut rig: Rig, blocks: usize) -> [Vec; 2] { } if let (Some(gate), Some(handles)) = (gate.as_mut(), rig.handles.as_mut()) && block == 15 + && offsets { gate.write_published(|stamp| { for channel in 0..2 { @@ -1241,9 +1243,9 @@ fn the_scalar_oracle_renders_the_banks_fader_events() { let plain_on = |backend| { let prepared = host_core::test_only_prepare_host_runtime_on(&compiled, &caps, backend) .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); - render_parity(Rig::from(prepared, &model), BLOCKS) + render_parity(Rig::from(prepared, &model), BLOCKS, true) }; - let live_on = |backend, between: bool| { + let live_with = |backend, between: bool, offsets: bool| { let (prepared, handles) = if between { host_core::prepare::test_only_prepare_host_runtime_between_render_calls_on( &compiled, &caps, &request, backend, @@ -1256,11 +1258,12 @@ fn the_scalar_oracle_renders_the_banks_fader_events() { .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); let mut rig = Rig::from(prepared, &model); rig.handles = Some(handles); - render_parity(rig, BLOCKS) + render_parity(rig, BLOCKS, offsets) }; + let live_on = |backend, between| live_with(backend, between, true); let mut unautomated = model.clone(); unautomated.automation.clear(); - let without = render_parity(Rig::new(&unautomated), BLOCKS); + let without = render_parity(Rig::new(&unautomated), BLOCKS, true); // Delivery between render calls forms the fused fader-matrix bank. builtins_compiler::test_only_reset_fader_matrix_witness(); let fused_bank = live_on(Backend::Simd4, true); @@ -1289,9 +1292,11 @@ fn the_scalar_oracle_renders_the_banks_fader_events() { ] { assert_ne!(bank, without, "{label}: the automation is heard"); if label != "no live control" { + // U3 fold, MINOR 7: against the same live rig with no offsets write, so only the + // writes can make the difference. assert_ne!( bank, - plain_on(Backend::Simd4), + live_with(Backend::Simd4, label == "between render calls", false), "{label}: the offsets writes are heard" ); } @@ -1372,6 +1377,83 @@ fn a_lane_a_successor_adds_jumps_to_its_curve() { } } +/// #1648 D2, departure 4 (batch 17 U3 fold): on plans with no live cells a VCA edit cannot reach a +/// carried automated lane as an offsets record, so the lane's first block takes the adoption rule +/// (`Adoption::Compare` in the builtins compiler): track 1 rides its fader linearly under VCA +/// `v`, and a successor with `v` at -6 dB (the same ride, so the table continues) is swapped in +/// at block 6 with a 240-sample fader jump. The swap block differs from both sessions rendered +/// fresh (the lane ramps), and from the block after the jump and its first grid ramp every block +/// equals the successor's session rendered fresh. +/// +/// Test value: red if the carried lane keeps the `S` its stage heard when no record moves it +/// (it would stay 6 dB high), or sets it at once. +#[cfg(feature = "test-support")] +#[test] +fn a_carried_lane_whose_s_moves_with_no_record_takes_the_adoption_rule() { + use successor::{Feed, QUANTUM, SWAP_BLOCK, Session, Successor, reference_run, swapped_run}; + let mut base = plain(2, 128); + base.control_smoothing = smoothing(5.0); + base.automation = vec![fader_entry( + &base, + 1, + ParameterChannel::Both, + vec![segment(AutomationShape::Linear, 0, 1 << 20, 0.0, -12.0)], + )]; + base.vcas = vec![session::Vca { + id: id("v"), + fader: session::DualMonoFader { + left_db: 0.0, + right_db: 0.0, + left_mute: false, + right_mute: false, + }, + members: vec![base.tracks[1].id.clone()], + }]; + let mut moved = base.clone(); + moved.vcas[0].fader.left_db = -6.0; + moved.vcas[0].fader.right_db = -6.0; + let a = Session::compile(base); + let b = Session::compile(moved); + let feed = Feed::new("fixture-source", 7); + let settled = SWAP_BLOCK + (240_usize + 64).div_ceil(QUANTUM); + assert!(settled < successor::BLOCKS); + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + let swapped = swapped_run(&a, &b, &[&feed], &[&feed], backend, Successor::Carry); + assert!( + swapped.restarted.is_empty(), + "{backend:?}: the stage carries" + ); + let reference = reference_run(&b, &[&feed], backend); + let without = reference_run(&a, &[&feed], backend); + assert_eq!( + swapped.blocks[SWAP_BLOCK - 1].bits, + without[SWAP_BLOCK - 1].bits, + "{backend:?}: the predecessor" + ); + assert_ne!( + swapped.blocks[SWAP_BLOCK].bits, reference[SWAP_BLOCK].bits, + "{backend:?}: the swap block ramps, it is not set" + ); + assert_ne!( + swapped.blocks[SWAP_BLOCK].bits, without[SWAP_BLOCK].bits, + "{backend:?}: the swap block moves toward the new S" + ); + for (block, (swapped, reference)) in swapped + .blocks + .iter() + .zip(&reference) + .enumerate() + .skip(settled) + { + assert_eq!(swapped.bits, reference.bits, "{backend:?}: block {block}"); + } + } +} + // #1647c: the held-ramp obligation (D1.6, batch 13b amendment, gate 8). /// The render sample at which gate 8's unmute ramp ends: `E`, the first sample the stage reports diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 1866aa06c..5904e3118 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -4576,17 +4576,23 @@ fn a_vca_move_over_an_automated_member_is_live_alike_on_both_hosts() { } /// #1649 gate 2 (both hosts). Ten live moves of `v` before one render give the bits of a twin that -/// made only the last, on both hosts (render reads the newest slot of the offsets cell, whole). +/// made only the last, on both hosts (render reads the newest slot of the offsets cell, whole); +/// after 28 blocks a second burst of ten (ending at -2 dB) again gives the twin's bits when the +/// twin makes only its last, and after each burst's jump the output equals the committed +/// session's boot. /// Three nested VCAs reach `t2`, declared and nested out of ID order, at 24, -24 and 2e-30 dB in /// ascending ID order: a move of the third to 1e-30 is `live` and renders the committed session's /// boot (the `S` bits it writes are the classifier gate's, /// `a_vca_move_writes_the_automated_lanes_offsets_cell`). /// -/// Test value: red if render applies an older slot than the newest committed one, if a slot mixes -/// two writes, or if a nested reach is composed otherwise than preparation. +/// Test value: red if render applies an older slot than the newest committed one, if render hears +/// only the first write of a cell (the second burst would not be heard), or if a nested reach is +/// composed otherwise than preparation. #[test] fn many_vca_moves_before_one_render_are_the_last_alike_on_both_hosts() { const EDIT: u64 = 4; + // Each burst's run: its jump settles inside it at every rate, and two fit the sources. + const SPAN: u64 = 28; for rate in RATES { let model = automated_vca_model(rate, |_| {}); let mut many = Rig::new(&model); @@ -4596,16 +4602,39 @@ fn many_vca_moves_before_one_render_are_the_last_alike_on_both_hosts() { for block in 0..EDIT { one.step(block); } - for step in 1..=10 { - let db = if step == 10 { -6.0 } else { -(step as f32) }; - let edit = [vca_offset(VCA.id, db, None)]; - assert_eq!(many.transaction(&edit), c_abi.transaction(&edit).0); - } - one.transaction(&[vca_offset(VCA.id, -6.0, None)]); - for block in EDIT..EDIT + 24 { - let (m, c, o) = (many.step(block), c_abi.step(block), one.step(block)); - assert_eq!(m, c, "{rate} Hz: block {block} alike on both hosts"); - assert_eq!(m, o, "{rate} Hz: block {block} is the last move's"); + for (burst, last) in [(0_u64, -6.0_f32), (1, -2.0)] { + for step in 1..=10 { + let db = if step == 10 { + last + } else { + -(step as f32) - 0.5 * burst as f32 + }; + let edit = [vca_offset(VCA.id, db, None)]; + assert_eq!(many.transaction(&edit), c_abi.transaction(&edit).0); + } + one.transaction(&[vca_offset(VCA.id, last, None)]); + // The committed session's boot: a twin that also missed every write after the first + // would still equal `many`, so each burst is held to the boot after its jump too. + let committed = snapshot_bytes(|payload, expected| many.command(payload, expected)); + let mut boot = Rig::boot(&committed, rate); + let first = EDIT + SPAN * burst; + let settled = first + settle_blocks(&mut many, model.control_smoothing_samples().fader); + assert!( + settled < first + SPAN, + "{rate} Hz: the jump settles inside the burst's run" + ); + for block in 0..first { + boot.step(block); + } + for block in first..first + SPAN { + let (m, c, o) = (many.step(block), c_abi.step(block), one.step(block)); + let b = boot.step(block); + assert_eq!(m, c, "{rate} Hz: block {block} alike on both hosts"); + assert_eq!(m, o, "{rate} Hz: block {block} is the last move's"); + if block >= settled { + assert_eq!(m, b, "{rate} Hz: block {block} renders the committed boot"); + } + } } let nested = automated_vca_model(rate, |model| { From 93146057e342fadbf83b0e55819b450afd981aa5 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 22:16:32 +0000 Subject: [PATCH 27/35] Keep an offsets ramp at a freeing entry; gate the held-ramp cases (#1647 attempt 4) F1: an offsets jump applied at the entry that frees the lane clears the held-through mark, so the release no longer cuts its ramp (gate P2, red on 86271e096's logic). F2: the nothing-owed release gate runs the q37 case with no grid point between the writes (red under 'release only when owed'). F3: P3 pins the D1.2 removal against the never-muted twin (red with D1.2 restored); D1.2 was observable and produced a worse output, removed by root decision B17-R11. #1648 residue: a hand move pending at a swap and one inside a rebuild keep their own length. B17-R12: the Compare backstop is unreachable from every shipped host (proof comment + debug_assert; release ramps over J). Attempt 4 of 5. Realtime policy 160; pcm_digest unchanged. Co-Authored-By: Claude Opus 5.5 --- ...7-render-moving-stored-fader-automation.md | 70 +++- ...assify-and-carry-fader-automation-edits.md | 38 +- crates/builtins-compiler/src/lib.rs | 21 +- crates/capi/src/runtime/live_tests.rs | 81 +++++ .../tests/stored_fader_automation.rs | 333 +++++++++++++++++- 5 files changed, 510 insertions(+), 33 deletions(-) diff --git a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md index 02aa65139..bd87afcdc 100644 --- a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md @@ -863,18 +863,20 @@ The U2 fold's review (MAJOR-A, MINOR-B, a NIT) and root's ruling on D1.2, folded `a_live_write_that_frees_a_lane_with_nothing_owed_releases_its_hold` (prepared muted; curve flat at `-30` dB over `[0, 2_000]`, then a fade to `0` dB at 10,000; a live unmute of 4,000 at 0 and of window 0 at 1,024: every completion sample from 1,087 is the curve's gain; quanta 128 and 64 and - the oracle). Red on `6fcd46e01` (completion sample 2,047 off the curve), checked once. -- **D1.2 removed: unobservable after R17-9 and R17-10.** While the stage holds the lane it only - stores gain operations, and the cell emits the same events with or without the report, so `owed` - is set alike. If a move is owed, the R17-9 landing at `E` rewrites the cell's whole state; if a - live write frees the lane early, the held-through release ends the hold; if nothing is owed and - the hold ends by itself, it ends at `E - 1`, where the report's hold ended too. So + the oracle). ~~Red on `6fcd46e01` (completion sample 2,047 off the curve), checked once.~~ + Corrected in U2 fold 3: this claim does not hold for the gate as committed. With D1.2 removed + nothing gave the cell a hold of the ended ramp in that case, so the gate stayed green under the + mutation "release only when owed" (the fold-2 verifier's finding); it was replaced (U2 fold 3, + F2). +- **D1.2: D1.2 was observable (P3) and produced a worse output; removed by root decision + B17-R11.** With D1.2 an unchanged jump inside the hold becomes an owed move at `E`, and the lane + departs from the never-muted reference; without it the lane equals the never-muted session from + `E` (gate `an_unchanged_jump_inside_the_hold_owes_no_move`, U2 fold 3, F3). The fold-2 argument + that D1.2 was unobservable is withdrawn. Removed with it: `LaneFaderAutomation::report_held_ramp`, `report_held_ramps`, the drain's held marks (`drain_fader_controls_marking`, folded back into `drain_fader_controls`), the scalar oracle's - marks and the now unused `selector_channels` are deleted. Mutation check (once): with the fix in - and D1.2 removed every gate stays green (`stored_fader_automation` 27/27, and the full check set - below). This withdraws constraint 1's report (batch 13b amendment) and the "D1.2 is kept" of the - U2 fold above. + marks and the then unused `selector_channels`. This withdraws constraint 1's report (batch 13b + amendment) and the "D1.2 is kept" of the U2 fold above. - **MINOR-B.** Each unreachable `debug_assert` condition in the driver carries a one-line proof and its release action (a hold or a skip; no panic; no NaN reaches the stage): `read_offsets`'s offsets-jump refusal, `cut`'s span, the driver's missing program cell, its span / slot / `hold_end` @@ -898,3 +900,51 @@ allocations. No edit under `crates/engine/src/realtime/`, `crates/automation` or **Attempt count (AGENTS.md five-attempt rule).** #1647 is at attempt 3 of 5: the implementation (#1647a-c), the U2 fold (59ca8e3b4) and U2 fold 2 (this section). A failed verification of fold 2 goes to root before attempt 4. + +## U2 fold 3 (batch 17): attempt 4 of 5 + +The fold-2 verification's findings (F1-F3) and root decision B17-R11, folded on +`codex/controls-batch-17k` over `2d08d9142`. Root-approved scope only. Uncommitted for the +coordinator. + +- **F1 (render fix: a regression from fold 2).** The held-through release (R17-10) overwrote a + hold that `offsets_jump` set at the same block entry: a live unmute of window 0 and an `S` + write at one entry cut the offsets jump's ramp at the next grid point. The free branch of + `AutomatedFaderCell::offsets_jump` now clears `obligation.held_through` after the stage takes + the operation; the jump's hold is newer than the ramp the write ended. A successor's carried + record goes through the same `offsets_jump` call at its first block (`read_offsets`), with the + carried obligation, so it takes the same treatment. Gate + `an_offsets_jump_at_the_entry_that_frees_the_lane_keeps_its_ramp` (the verifier's P2): + prepared muted, flat `-30` dB to 1,100 then a fade; unmute of window 0 and `S = -6` dB with a + 480-sample ramp at 1,024, with no unmute in progress and with a 4,000-sample unmute from 0; + from 1,024 the lane equals the never-muted twin that takes the same write; quanta 128, 64, 37; + bank at eight and four lanes, the fused path at both widths, and the scalar oracle. Red on + `86271e096`'s logic (the one line reverted), checked once. +- **F2 (void gate replaced).** `a_live_write_that_frees_a_lane_with_nothing_owed_releases_its_hold` + is now the verifier's P1b at quantum 37: an unmute of 4,000 at 0, an `S` write of `-6` dB (ramp + 200) at 962 under it (the held branch: the cell holds to 3,999), and an unmute of window 0 at + 999; no grid point lies between them, so nothing is owed. Every completion sample from 1,087 is + the composed curve; both lanes, and a left-only form whose right lane stays muted; all five + render forms. Red under the mutation "release only when owed", checked once. +- **F3 (B17-R11).** Gate `an_unchanged_jump_inside_the_hold_owes_no_move` (the verifier's P3): + jump length 16; curve flat `-20` dB to 1,024, linear `-21` to `-13` dB over `[1_024, 1_144]`, + flat `-13` after; prepared muted; unmute of window 1,060 at 0. From `E = 1_060` both lanes equal + the never-muted twin; quanta 128, 64, 37; all five forms. Red with D1.2 restored (the hunks of + the verifier's `lib.rs.d12`, ported to this tree), checked once. The U2 fold 2 record above now + states the D1.2 decision and corrects the replaced gate's "Red on `6fcd46e01`" line. + +The verifier's other probes (P1, P4, P5) are not committed: P1b covers P1's case, and P4 and P5 +pass with no mutation named that they alone catch. + +**Checks (x86-64 AVX2).** `cargo test --locked -p builtins-compiler --features test-support`: +86 pass. `-p host-core --all-features`: 561 pass (`stored_fader_automation` 30). `-p +control-plane --all-features`: 25 pass. `-p capi`: 220 pass, 1 ignored. `-p host-web --features +host-web/test-support --lib --test control_parity`: 175 pass. `realtime-policy`: ok, 160 regions +in 33 files. fmt; clippy `-D warnings` (builtins-compiler, host-core, capi; all targets, all +features); no-feature `cargo check --all-targets` of the same: clean. `./target/release/audit +capi`: `pcm_digest` `bffc21e857b5e134`, 0 violations, 0 allocations, 0 syscalls. No fixture or +byte moved. No edit under `crates/engine/src/realtime/`. + +**Attempt count (AGENTS.md five-attempt rule).** #1647 is at attempt 4 of 5: the implementation +(#1647a-c), the U2 fold, U2 fold 2 and U2 fold 3 (this section). A failed verification of this +attempt leaves one attempt. diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index b1d133246..251cc58bc 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -601,6 +601,23 @@ takes its adoption jump over `J`, even when the same transaction also moves `S` `Adoption` is now ordered, and `adopt` keeps the stronger of its own adoption and one the carried cell still owed (NIT 3). +**Compare reachability (root ruling B17-R12): unreachable from every shipped host.** Proof: the +join (`join_strips` -> `join_level_records`) writes an offsets record to the successor's cell +whenever the transaction moves a carried automated lane's effective `S`, and it is `Live` (not +`Same`) exactly when the successor attaches live controls (`live_strips = attach_live`). Every +host preparation attaches them: the C ABI (`crates/capi/src/runtime/mod.rs`, both +`prepare_host_runtime_with_live_lanes[_successor]`) and the browser (`prepare_web_host`, its +three branches, `..._with_live_controls_and_spectrum[_collection]_successor` and +`prepare_host_runtime_with_selected_meters_successor`) pass `Some(live_controls)`, whatever the +`HostLiveLanes` selection, and `live_control_strips` lists every track and submix. So on a +shipped plan the first block always reads the cell's own record and never raises `Compare` for +an `S` change. Only host-core's plain entries (`prepare_host_runtime_successor` and the other +`None` forms, which no shipped host calls) prepare a lane with no live cells, where `Compare` is +the backstop and ramps over `J`. `read_offsets` now carries +`debug_assert!(superseded.is_none(), ...)` at the raise with this proof (`superseded` is `Some` +exactly on a lane with live cells); in release the action stays the `J` ramp. Every test of +builtins-compiler, host-core, control-plane, capi and host-web passes with it. + **MINOR 8.** The held-ramp re-report under `Reposition` is in `settle_adoption`, not D1.2, so it survives U2 fold 2. It changes no sample: a held stage only remembers either operation, and the obligation follows `free_from`. It is documented there as bookkeeping (M14 stays green). The @@ -631,9 +648,28 @@ In `crates/host-core/tests/stored_fader_automation.rs`: **Not done here.** R2b and reviewer C's hand-move form need #1650b's hand offset, which is not on this branch. The VCA forms above exercise the same records. R4 is not a discriminating test on this branch, because the offset is not yet heard (#1650a). Its red check belongs to the merged -tree. +tree. (Done on the merged tree: see "U3 fold residue" below.) **Numbers.** `lane_slot_bytes` 224 -> 304 (the carried record and the adopted `S`, both channels), measured by `fader_automation_sizes()`. No byte gate moved. `realtime-policy`: ok, 160 regions in 33 files. `audit capi`: `pcm_digest` `bffc21e857b5e134`, 0 violations, 0 allocations. No edit under `crates/engine/src/realtime/`. + +## U3 fold residue (batch 17, on the merged tree with #1650b) + +- **R2b.** `a_hand_move_pending_at_a_swap_keeps_its_length` (`set_control::automated_carry`): a + live hand move of the riding fader (`fader_db`, both lanes, `-6` dB over 4,000 samples), unread + when a rebuild commits; moving and flat ride. Every block equals the uninterrupted engine, the + move is heard, the successor is adopted, `LIVE_VALUES_SUPERSEDED` is equal. Red with the carry + drain (`carry_offsets`) reading nothing, checked once. +- **Reviewer C's case as a hand move.** `a_hand_move_inside_a_rebuild_keeps_its_own_length`: the + same hand move with an explicit 4,096-sample ramp in the transaction that adds a track (rebuild) + equals the hand move sent alone (`live`), moving and flat ride. Red with the join's offsets + records dropped, checked once. No hand-move defect found. +- **R4 red check (merged tree).** `an_offset_only_upsert_inside_a_rebuild_renders_as_it_does_alone` + is red when the join's offsets record takes ramp 0 (a jump over another length), and red when + the record is dropped and the `Compare` backstop removed. It stays green when only the record + is dropped: the upsert sent alone ramps over the session's length `J`, and the `Compare` + backstop also ramps over `J` to the same bits, so a lost record alone is not observable in that + case (the hand-move and VCA forms with their own lengths are red under it). +- Part C (B17-R12) is recorded in the U3 fold section above. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 4cb3c66f9..109d1a2f2 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -822,7 +822,8 @@ struct HeldRampObligation { /// The stage held the gain through the last sample of the block just rendered. A next block /// that finds the gain free with no move due had its hold cleared by a live write at its /// entry, so the cell's hold of the ramp that write ended is released there, owed or not - /// (R17-10). + /// (R17-10). An offsets jump applied on the free lane at that entry clears it: the jump's own + /// hold is newer than the ramp the write ended (U2 fold 3, F1). held_through: bool, } @@ -1033,6 +1034,15 @@ impl LaneFaderAutomation { && let Some(sum) = adopted_sum && sum.to_bits() != cell.offsets_sum.to_bits() { + // Unreachable on a lane with live cells (B17-R12): the join writes an offsets + // record to the successor's cell whenever its `S` differs from the committed one + // (`join_level_records`), and every C ABI and browser plan attaches live cells to + // every strip, so only a plan prepared with no live controls (a host-core entry no + // shipped host calls) takes this backstop. In release it ramps over `J`. + debug_assert!( + superseded.is_none(), + "the adoption rule stands in for a live offsets record" + ); cell.offsets_sum = sum; cell.adoption = cell.adoption.max(Adoption::Compare); } @@ -1114,8 +1124,9 @@ impl AutomatedFaderCell { /// the gain (#1638 D2), so the target its ramp aims for is no longer one the cell knows: the /// cell forgets its target (a fresh state, whose next free grid event always retargets) and /// holds until the held ramp's last sample (released at the entry of a block whose live write - /// frees the gain early, R17-10). With no node time (a plan with no timeline) the cell's own - /// target is the jump's value. + /// frees the gain early, R17-10). On a free lane the jump's own hold is never released by + /// that rule: a live write that frees the gain at the same entry ended an older ramp. With no + /// node time (a plan with no timeline) the cell's own target is the jump's value. /// /// `false` when the composed value has no fader gain, the program has no such cell, or the /// stage refuses the operation (unreachable: the curve is in the fader domain, @@ -1184,6 +1195,10 @@ impl AutomatedFaderCell { if !stage.apply(channel, operation) { return false; } + // This jump's hold is newer than the held ramp a live write ended at this entry, so the + // block's R17-10 release must not cut it (#1647 U2 fold 3, F1). A successor's carried + // record takes this same path at its first block, with the carried obligation. + self.obligation.held_through = false; self.state.seed_target(value); if let Some(start) = start { self.state diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 8b4b7ef1f..ea74d19cd 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -8921,6 +8921,87 @@ mod set_control { } } + /// A hand move (#1650b) of `document`'s first track's fader to `db` on both lanes over + /// `ramp`: on an automated fader it is stored as the ride's offset and moves `S`. + fn hand_move(document: &str, db: f32, ramp: Option) -> SessionEdit { + set( + builtin(&first_track(document), FADER_DB, Lane::Both), + db, + ramp, + ) + } + + /// U3 fold residue R2b (#1648, #1650b). A live hand move on the riding fader with its own + /// 4,000-sample ramp, still unread when a rebuild commits (no block between them), is + /// carried into the successor as the VCA move is: every block equals the uninterrupted + /// engine's, the move is heard, the successor is adopted, and `LIVE_VALUES_SUPERSEDED` + /// holds the same count. On the moving and the flat ride. + /// + /// Test value: red if a hand move's offsets write misses the carry drain (checked once) + /// or its record loses its ramp word across the swap: the successor then jumps over the + /// fader length from its prepared `S`. A hand move reaches the offsets cell through the + /// fader row's stored offset, not the VCA row, which the VCA forms do not exercise. + #[test] + fn a_hand_move_pending_at_a_swap_keeps_its_length() { + bench_support::alloc::assert_installed(); + for (ride, document) in [("moving ride", riding()), ("flat ride", flat())] { + let label = &format!("{ride}, a hand move of 4,000 samples"); + let model = parse_session_json(&document).expect("model"); + let mut rigs = warm(&document); + let edit = hand_move(&document, -6.0, Some(4_000)); + for rig in &mut rigs[..2] { + send( + rig, + core::slice::from_ref(&edit), + protocol::EditPath::Live, + label, + ); + } + send( + &mut rigs[0], + &add_muted_track(&model, "added"), + protocol::EditPath::Rebuild, + label, + ); + let (first, heard) = lockstep(&mut rigs, 44); + assert_eq!(rigs[0].summary().3, 0, "{label}: the successor is adopted"); + assert_eq!(first, None, "{label}: first difference (block, sample)"); + assert!(heard, "{label}: the move is heard"); + let counts = [superseded(&mut rigs[0]), superseded(&mut rigs[1])]; + assert_eq!( + counts[0], counts[1], + "{label}: LIVE_VALUES_SUPERSEDED [a, u]" + ); + } + } + + /// U3 fold residue, reviewer C's case as a hand move (#1648, #1650b). A hand move with an + /// explicit 4,096-sample ramp in the same transaction as a structural edit (the + /// transaction rebuilds) reaches the carried automated lane with its own length: every + /// block equals the same hand move sent alone (`live`). On the moving and the flat ride. + /// + /// Test value: red if the join writes no offsets record for a hand move's stored offset + /// (the successor's `S` then reaches the lane by the adoption rule, over the fader length + /// `J`, not over 4,096 samples). + #[test] + fn a_hand_move_inside_a_rebuild_keeps_its_own_length() { + bench_support::alloc::assert_installed(); + for (ride, document) in [("moving ride", riding()), ("flat ride", flat())] { + let label = &format!("{ride}, a hand move of 4,096 samples"); + let model = parse_session_json(&document).expect("model"); + let mut rigs = warm(&document); + let edit = hand_move(&document, -6.0, Some(4_096)); + let mut edits = vec![edit.clone()]; + edits.extend(add_muted_track(&model, "added")); + send(&mut rigs[0], &edits, protocol::EditPath::Rebuild, label); + send(&mut rigs[1], &[edit], protocol::EditPath::Live, label); + let (first, heard) = lockstep(&mut rigs, 44); + assert_eq!(rigs[0].summary().3, 0, "{label}: the successor is adopted"); + assert_eq!(first, None, "{label}: first difference (block, sample)"); + assert!(heard, "{label}: the move is heard"); + } + } + /// U3 fold, MAJOR 2 (R1). A live VCA move heard (mid-jump after three blocks, settled /// after twelve), then a rebuild that keeps the ride: the carried lane continues with no /// adoption jump, and every block equals the uninterrupted engine's. diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index 0c9e5af20..6472a5bf4 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -2033,19 +2033,168 @@ fn a_live_write_that_frees_a_held_lane_releases_its_hold() { } } -/// R17-10 with nothing owed (MAJOR-A of the U2 fold review): a live write that frees a lane the -/// stage held through the end of the previous block releases the cell's hold there, whether or not -/// a move is owed. The track is prepared muted on a curve flat at `-30` dB over `[0, 2_000]` (the -/// seeded value, so the cell gives no event inside the hold and owes nothing) and then fading to -/// `0` dB at 10,000; a live unmute of 4,000 samples is drained at 0, and a live unmute of window 0 -/// at 1,024 ends it there. From 1,024 the lane follows the curve: at each completion sample -/// `64k + 63` it equals the curve's gain exactly, at quantum 128 and 64 and on the oracle. +/// A rig of `model` at `quantum` with live controls and its plan's gate, on one of the five +/// render forms [`acted`] compares: the live bank at eight and four lanes, the same between +/// render calls (the fused path), and the scalar oracle (R17-4). +#[cfg(feature = "test-support")] +fn form_rig(model: &SessionModel, quantum: u32, form: usize) -> Rig { + use graph_compiler::Backend; + let mut model = model.clone(); + model.quantum_frames = quantum; + let document = canonical_session_json(&model).expect("canonical"); + let caps = rig_caps(&model); + let compiled = host_core::compile_host_session(&document, &caps) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + let request = host_core::HostLiveControlRequest::default(); + let (backend, between) = match form { + 0 => (Backend::Simd8, false), + 1 => (Backend::Simd4, false), + 2 => (Backend::Simd8, true), + 3 => (Backend::Simd4, true), + _ => (Backend::Scalar, false), + }; + let (mut prepared, handles) = if between { + host_core::prepare::test_only_prepare_host_runtime_between_render_calls_on( + &compiled, &caps, &request, backend, + ) + } else { + host_core::test_only_prepare_host_runtime_with_live_controls_on( + &compiled, &caps, &request, backend, + ) + } + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + let gate = prepared.plan.take_revision_gate(); + let mut rig = Rig::from(prepared, &model); + rig.handles = Some(handles); + rig.gate = gate; + rig +} + +/// One scripted act on track 0: a live mute record `(lanes, muted, window)`, an offsets record +/// `(S, ramp)` on both channels, and a fader jump length, each optional, written together at the +/// first block entry at or after `at`. +#[cfg(feature = "test-support")] +#[derive(Clone, Copy)] +struct Act { + at: u64, + mute: Option<(builtins::BuiltinLaneSelector, bool, u32)>, + offsets: Option<(f64, u32)>, + jump: Option, +} + +/// An [`Act`] at `at` that writes nothing until its fields are set. +#[cfg(feature = "test-support")] +const fn act(at: u64) -> Act { + Act { + at, + mute: None, + offsets: None, + jump: None, + } +} + +/// The output of `model` at `quantum` over `frames` with `acts`, the same bits on each of the +/// five forms of [`form_rig`]; and the block entry each act landed on. +#[cfg(feature = "test-support")] +fn acted( + model: &SessionModel, + quantum: u32, + frames: u64, + acts: &[Act], +) -> ([Vec; 2], Vec) { + use builtins_compiler::TestOnlyWriteGate; + let mut outs = Vec::new(); + let mut entries = Vec::new(); + for form in 0..5 { + let mut rig = form_rig(model, quantum, form); + entries.clear(); + while rig.rendered < frames { + let q = rig.quantum as u64; + for act in acts { + if rig.rendered < act.at || rig.rendered >= act.at + q { + continue; + } + entries.push(rig.rendered); + if let Some(fader) = act.jump { + rig.write_fader_jump(model, fader); + } + let handles = rig.handles.as_mut().expect("live controls"); + rig.gate + .as_mut() + .expect("the plan's gate") + .write_published(|stamp| { + if let Some((lanes, muted, window)) = act.mute { + handles.strip_controls[0].fader.write( + stamp, + builtins_compiler::TrackFaderRecord::Mute { + lanes, + muted, + smoothing_samples: window, + }, + ); + } + if let Some((offsets_sum, smoothing_samples)) = act.offsets { + for channel in 0..2 { + handles.strip_controls[0].fader.write_offsets( + stamp, + channel, + builtins_compiler::FaderOffsetsRecord { + offsets_sum, + smoothing_samples, + }, + ); + } + } + }); + } + rig.block(); + } + outs.push(rig.out.map(|mut plane| { + plane.truncate(frames as usize); + plane + })); + } + for (form, other) in outs.iter().enumerate().skip(1) { + for plane in 0..2 { + assert_eq!( + first_difference(&outs[0][plane], &other[plane]), + None, + "quantum {quantum}: form {form} against the eight-lane bank, plane {plane}" + ); + } + } + (outs.swap_remove(0), entries) +} + +/// `model` with track 0 prepared unmuted: the never-muted twin. +#[cfg(feature = "test-support")] +fn never_muted(model: &SessionModel) -> SessionModel { + let mut model = model.clone(); + ( + model.tracks[0].fader.left_mute, + model.tracks[0].fader.right_mute, + ) = (false, false); + model +} + +/// R17-10 with nothing owed (MAJOR-A of the U2 fold review, U2 fold 3 F2): a live write that +/// frees a lane the stage held through the end of the previous block releases the cell's hold +/// there, whether or not a move is owed. The track is prepared muted on a curve flat at `-30` dB +/// over `[0, 2_000]` and then fading to `0` dB at 10,000; at quantum 37 a live unmute of 4,000 +/// samples is drained at 0, an offsets write `S = -6` dB (ramp 200) at 962 under that ramp (the +/// held branch of the offsets jump: the cell forgets its target and holds to 3,999), and a live +/// unmute of window 0 at 999 frees the lane. No grid point lies between 962 and 999, so the cell +/// remembers no event and owes nothing. From 1,087 the lane follows the composed curve: at each +/// completion sample `64k + 63` it equals `checked_fader_gain(vca_compose_db(curve, S))` exactly, +/// on every render form; both lanes, and a left-only form whose right lane stays muted. /// -/// Test value: red if the release is made only when a move is owed (the cell keeps a hold of the -/// ended ramp to 3,999, so the fade's grid events from 2,000 are only remembered on a free stage -/// and the lane stays at `-30` dB while the curve moves). +/// Test value: red if the release is made only when a move is owed (the cell keeps the offsets +/// jump's hold to 3,999, so the fade's grid events from 2,000 are only remembered on a free +/// stage and the lane stays at the gain the unmute left while the curve moves). +#[cfg(feature = "test-support")] #[test] fn a_live_write_that_frees_a_lane_with_nothing_owed_releases_its_hold() { + use builtins::BuiltinLaneSelector::{Both, Left}; const FRAMES: u64 = 6_144; let (model, entry) = muted_ride( vec![ @@ -2054,17 +2203,163 @@ fn a_live_write_that_frees_a_lane_with_nothing_owed_releases_its_hold() { ], 10.0, ); - let out = scripted_across(&model, &[128, 64], FRAMES, &|rig| match rig.rendered { - 0 => live_mute(rig, builtins::BuiltinLaneSelector::Both, false, 4_000), - 1_024 => live_mute(rig, builtins::BuiltinLaneSelector::Both, false, 0), - _ => {} - }); - for plane in &out { + for (lanes, left_only) in [(Both, false), (Left, true)] { + let (out, entries) = acted( + &model, + 37, + FRAMES, + &[ + Act { + mute: Some((lanes, false, 4_000)), + ..act(0) + }, + Act { + offsets: Some((-6.0, 200)), + ..act(962) + }, + Act { + mute: Some((lanes, false, 0)), + ..act(999) + }, + ], + ); + assert_eq!(entries, [0, 962, 999], "each act lands at its own entry"); for t in (1_024 + 63..FRAMES).step_by(64) { + let at = t as usize; assert_eq!( - plane[t as usize].to_bits(), - gain(curve(&entry, t as i64)).to_bits(), - "completion sample {t}" + out[0][at].to_bits(), + composed(curve(&entry, t as i64), -6.0).to_bits(), + "left only {left_only}: left completion sample {t}" + ); + let right = if left_only { 0.0 } else { out[0][at] }; + assert_eq!( + out[1][at].to_bits(), + right.to_bits(), + "left only {left_only}: right at {t}" + ); + } + } +} + +/// U2 fold 3, F1: an offsets jump on the free lane at the block entry whose live write frees it +/// keeps its own hold. The track is prepared muted on a curve flat at `-30` dB to 1,100 and then +/// fading to `0` dB at 10,000; at the entry 1,024 a live unmute of window 0 and an offsets write +/// `S = -6` dB with a 480-sample ramp land together (and, in a second form, a live unmute of +/// 4,000 samples is in progress from 0). From 1,024 both lanes equal the never-muted session that +/// takes the same offsets write at 1,024, at quantum 128, 64 and 37, on every render form. +/// +/// Test value: red if the R17-10 release of the ended unmute overwrites the hold the offsets +/// jump set at the same entry (the fade's grid events from 1,100 cut the 480-sample ramp). +#[cfg(feature = "test-support")] +#[test] +fn an_offsets_jump_at_the_entry_that_frees_the_lane_keeps_its_ramp() { + use builtins::BuiltinLaneSelector::Both; + const FRAMES: u64 = 4_096; + let (model, _) = muted_ride( + vec![ + segment(AutomationShape::Linear, 0, 1_100, -30.0, -30.0), + segment(AutomationShape::Linear, 1_100, 10_000, -30.0, 0.0), + ], + 10.0, + ); + let twin = never_muted(&model); + let freeing = Act { + mute: Some((Both, false, 0)), + offsets: Some((-6.0, 480)), + ..act(1_024) + }; + for quantum in [128, 64, 37] { + let (reference, _) = acted( + &twin, + quantum, + FRAMES, + &[Act { + offsets: Some((-6.0, 480)), + ..act(1_024) + }], + ); + for ramping in [false, true] { + let mut acts = vec![freeing]; + if ramping { + acts.insert( + 0, + Act { + mute: Some((Both, false, 4_000)), + ..act(0) + }, + ); + } + let (out, entries) = acted(&model, quantum, FRAMES, &acts); + let free = *entries.last().expect("the freeing act") as usize; + for plane in 0..2 { + assert_eq!( + first_difference(&out[plane][free..], &reference[plane][free..]) + .map(|at| at + free), + None, + "quantum {quantum}, ramping {ramping}: plane {plane} renders the never-muted \ + lane from {free}" + ); + } + } + } +} + +/// Root decision B17-R11 (U2 fold 3, F3): an unchanged jump inside the hold owes no move. The +/// fader jump length is 16; the curve is flat at `-20` dB to 1,024, linear from `-21` to `-13` dB +/// over `[1_024, 1_144]` (exactly `-20` at 1,039, the jump's completion, so the jump at 1,024 +/// changes nothing) and flat at `-13` after. The track is prepared muted and a live unmute of +/// 1,060 samples is drained at 0: the hold ends at `E = 1_060` with no grid point between 1,024 +/// and `E`. From `E` both lanes equal the never-muted session at quantum 128, 64 and 37, on every +/// render form. +/// +/// Test value: red if a held ramp the live drain starts is reported to the cell as a hold of its +/// own (the removed D1.2): the unchanged jump is then remembered, a move is owed at `E`, and the +/// lane departs from the never-muted session. +#[cfg(feature = "test-support")] +#[test] +fn an_unchanged_jump_inside_the_hold_owes_no_move() { + use builtins::BuiltinLaneSelector::Both; + const FRAMES: u64 = 2_048; + const E: usize = 1_060; + let (model, entry) = muted_ride( + vec![ + segment(AutomationShape::Linear, 0, 1_024, -20.0, -20.0), + segment(AutomationShape::Linear, 1_024, 1_144, -21.0, -13.0), + segment(AutomationShape::Linear, 1_144, 4_000, -13.0, -13.0), + ], + 10.0, + ); + assert_eq!( + curve(&entry, 1_039).to_bits(), + (-20.0_f32).to_bits(), + "the jump at 1,024 completes on the value it starts from" + ); + let twin = never_muted(&model); + for quantum in [128, 64, 37] { + let (out, _) = acted( + &model, + quantum, + FRAMES, + &[Act { + mute: Some((Both, false, E as u32)), + jump: Some(16), + ..act(0) + }], + ); + let (reference, _) = acted( + &twin, + quantum, + FRAMES, + &[Act { + jump: Some(16), + ..act(0) + }], + ); + for plane in 0..2 { + assert_eq!( + first_difference(&out[plane][E..], &reference[plane][E..]).map(|at| at + E), + None, + "quantum {quantum}: plane {plane} renders the never-muted lane from E" ); } } From 0fac00ee9379fe41dd7707069a723031acde20c2 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 23:01:24 +0000 Subject: [PATCH 28/35] Keep a live level edit's ramp across a Reposition swap (#1648 attempt 3) A table-only automation edit (any position) repositioned the cell at the swap, cleared the offsets jump's hold and landed over J, cutting a pending, same- transaction or in-flight VCA or hand move (watermark EXACT). The repositioned cell now holds a free stage's running ramp again and compares the curve at the ramp's end; a move heard before the swap lands over max(J, remaining), so it ends when it was due. Gates: 16 cases across a table change (red at HEAD); the superseded count when a carried move is replaced unheard. Test forms gate Simd8 on avx2 (cross targets). Realtime policy 160; pcm_digest unchanged. Attempt 3 of 5. Co-Authored-By: Claude Opus 5.5 --- ...assify-and-carry-fader-automation-edits.md | 44 ++++ crates/builtins-compiler/src/lib.rs | 146 +++++++++--- crates/capi/src/runtime/live_tests.rs | 207 +++++++++++++++++- .../tests/stored_fader_automation.rs | 37 ++-- 4 files changed, 386 insertions(+), 48 deletions(-) diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index 251cc58bc..ddc96a3f7 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -673,3 +673,47 @@ No edit under `crates/engine/src/realtime/`. backstop also ramps over `J` to the same bits, so a lost record alone is not observable in that case (the hand-move and VCA forms with their own lengths are red under it). - Part C (B17-R12) is recorded in the U3 fold section above. + +## U3 fold 2 (batch 17): attempt 3 of 5 + +Defect (verifier, at 93146057e): at a `Reposition` adoption (a table-only rebuild of the ride), a +live level edit's own ramp was cut to the fader length `J` on a moving curve, while the watermark +reported EXACT. `reposition` dropped the hold of the edit's ramp, and the adoption rule compared +the curve at the block's first sample with a stage target taken at the ramp's end, so they +differed and the block opened with a landing over `J` (0.047 from the uninterrupted engine). + +Fix (`crates/builtins-compiler/src/lib.rs`, `settle_adoption` and the driver's first block; no +edit under `crates/automation` or `crates/engine/src/realtime/`): +- On a free stage still in a ramp (`remaining > 0`), the repositioned cell holds that ramp again + until its last sample, and the adoption rule compares the curve where the ramp ends (with the + cell's discontinuity clamp, `curve_toward`, now shared with `offsets_jump`) with the stage's + target. An unmoved target keeps the edit's own ramp with no landing (an edit applied at the + first block, pending or in the rebuild's transaction). +- When that target moved (a heard edit whose target the new table gives in other bits, even by + `f32` rounding), the landing length is `max(J, remaining)`: the edit still ends when it was due, + on the new table's curve, and a moved target never lands faster than `J`. A landing longer than + `J` is landed alone as a one-sample piece (`SeekMode::Ramp` over that length), and the rest of + the block runs from its second sample with `J`, so the block's own discontinuities still jump + over `J`. A landing of `J` keeps the old path. +- A held stage is unchanged (the batch 13b report of the held ramp). + +Gates (`crates/capi/src/runtime/live_tests.rs`, `automated_carry`, C ABI plans): +- `a_level_move_across_a_table_change_keeps_its_length`: a VCA move and a hand move, each with its + own 4,000 samples; the ride split at 10,000 (equal values up to rounding) and a change only in + its last 2,000 samples; the move pending at the swap, in the rebuild's transaction, and heard 1 + and 12 blocks before it. 44 blocks, allocation-free, adopted, max `|a - u| < 1e-3`. All 16 cases + red at 93146057e (0.047, 0.046 heard 1, 0.024 heard 12). With the fix: 0 in 12 cases, 4.6e-6 + heard 12 (the new table's rounding). The hand-move split keeps the offset the move stored. +- `a_carried_move_replaced_unheard_counts_as_superseded` (MINOR): "a rebuild that moves again" and + "a move, a rebuild, a move", moving and flat ride: every block and `LIVE_VALUES_SUPERSEDED` equal + the uninterrupted engine's. Both cases red with `superseded.add_carried(1)` removed from + `read_offsets`, checked once each. +- NIT: the two hand-move residue gates run on C ABI plans (the shipped preparer). Under the + carry-drain and join-write mutations a debug build stops first at the B17-R12 `debug_assert` + (the panic in render aborts the test process, red); a release build reaches the bit assert (red), + checked once each. Recorded in their test value. + +Also in this attempt (coordinator): `crates/host-core/tests/stored_fader_automation.rs` selected +`Backend::Simd8` in `form_rig` on every target; the forms are now a `FORMS` list whose eight-lane +entries carry `cfg(target_feature = "avx2")`, so `acted` compares five forms on x86-64 and three +elsewhere. `scripts/check-cross-targets.sh` passes. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 109d1a2f2..7f9acb477 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -1145,16 +1145,11 @@ impl AutomatedFaderCell { let completion = controls::FADER_CELL_RULE.completion; let start = span.and_then(|span| node_time_at(span, 0)); let value = match (start, program.cell(self.cell as usize)) { - (Some(start), Some(cell)) => { - let mut at = automation::completion_of(completion, start, ramp); - let ahead = cell.discontinuities(); - if let Some(&next) = ahead.get(ahead.partition_point(|&time| time <= start)) - && next <= at - { - at = next - 1; - } - automation::value_at(cell.segments(), at) - } + (Some(start), Some(cell)) => curve_toward( + &cell, + start, + automation::completion_of(completion, start, ramp), + ), (Some(_), None) => return false, (None, _) => match self.state.target() { Some(value) => value, @@ -1210,6 +1205,18 @@ impl AutomatedFaderCell { // REALTIME_POLICY_END // REALTIME_POLICY_BEGIN +/// The value a ramp started at node time `start` and completing at `at` aims for: the curve at +/// `at`, but never past the next discontinuity after `start`, which jumps of its own (the rule of +/// the cell's own `target_at`). +fn curve_toward(cell: &automation::CellProgram<'_>, start: i64, at: i64) -> f32 { + let ahead = cell.discontinuities(); + let at = match ahead.get(ahead.partition_point(|&time| time <= start)) { + Some(&next) if next <= at => next - 1, + _ => at, + }; + automation::value_at(cell.segments(), at) +} + /// The node time of sample `offset` of a node block mapped by `span` (`offset` may lie past the /// block: the mapping extends forward), or `None` past the `i64` range. Checked: a span saturated /// at the `i64` edges (R17-5) never wraps here. @@ -1474,13 +1481,18 @@ impl<'a> FaderEventDriver<'a> { }); let hold_now = hold(lane, channel); let mut mode = controls::FADER_CELL_RULE.seek; - if settle_adoption( + // A landing longer than `J`: the remainder of the free stage's own ramp (U3 fold + // 2), landed alone at the block's first sample so the block's discontinuities + // still jump over `J`. + let mut long_landing = None; + if let Some(length) = settle_adoption( core::mem::replace(adoption, Adoption::None), AdoptionBlock { program: &cell_program, span, offsets_sum: *offsets_sum, hold: hold_now, + jump, }, state, ) && step.is_none() @@ -1489,6 +1501,7 @@ impl<'a> FaderEventDriver<'a> { // over the block's jump length, to the curve at the jump's completion. step = Some((0, span.start)); mode = automation::SeekMode::Ramp; + long_landing = (length > jump).then_some(length); } let (free_from, at, release) = obligation.block(hold_now, frames); if release && let Some(first) = span_time(span.start, step, 0) { @@ -1520,26 +1533,73 @@ impl<'a> FaderEventDriver<'a> { refused(); continue; }; + let end = end.flatten(); + let (events, landing, base) = match long_landing { + // U3 fold 2: the landing alone, a one-sample piece over its own length (it + // commits the target and holds the grid until its end, as the cell's own + // `Jump` does), then the rest of the block from its second sample. A free + // stage has no cut (`end` is `None`); a cut at the first sample (a move due + // there) takes the landing over `J` below instead. + Some(length) if end.is_none() => { + let one = automation::NodeSpan::new(span.start, Some((0, span.start)), 1); + let rest = automation::NodeSpan::new(span.start + 1, None, frames - 1); + // Unreachable: a step at 0 lies inside one sample, and the block's node + // times were checked against the `i64` edge above. In release the channel + // is skipped: it emits nothing and the lane holds its gain. + let (Ok(one), Ok(rest)) = (one, rest) else { + refused(); + continue; + }; + let mut landing_events = automation::CellEvents::new( + cell_program, + state, + one, + automation::SeekMode::Ramp, + length, + ); + let landing = landing_events.next(); + let state = landing_events.into_state(); + let events = (frames > 1).then(|| { + automation::CellEvents::new( + cell_program, + state, + rest, + controls::FADER_CELL_RULE.seek, + jump, + ) + }); + (events, landing, 1) + } + _ => ( + Some(automation::CellEvents::new( + cell_program, + state, + node, + mode, + jump, + )), + None, + 0, + ), + }; let mut cursor = FaderCellCursor { lane, channel, offsets_sum: *offsets_sum, program: cell_program, jump, - events: Some(automation::CellEvents::new( - cell_program, - state, - node, - mode, - jump, - )), - base: 0, + events, + base, next: None, obligation, free_from, - end: end.flatten(), + end, }; - cursor.pull(); + if landing.is_some() { + cursor.next = landing; + } else { + cursor.pull(); + } *slot = Some(cursor); used += 1; } @@ -1640,10 +1700,12 @@ struct AdoptionBlock<'p, 'a> { span: TimelineSpan, offsets_sum: f64, hold: Option, + /// The block's jump length `J`. + jump: u32, } /// #1648 D2, at an adopted channel's first block: settle what [`LaneFaderAutomation::adopt`] left -/// owed, and whether the block must open with the adoption jump. +/// owed, and whether the block must open with the adoption jump, and over how many samples. /// /// A [`Adoption::Reposition`] places the cursor at the block's node time in the cell's own table /// (#1637 D4) and reports the stage's held ramp again (batch 13b: `reposition` drops the one the @@ -1651,11 +1713,18 @@ struct AdoptionBlock<'p, 'a> { /// operation of either kind, and the obligation is decided by where the gain is free, not by the /// event's flag. It keeps the repositioned cell's events what the cell gives under a hold (held /// events as `RememberGain`, as the test-only event probe reports them); batch 17 U3 fold, -/// MINOR 8: kept as bookkeeping, with no audible gate. +/// MINOR 8: kept as bookkeeping, with no audible gate. On a free stage still in a ramp (a live +/// level edit over its own length, applied at this block's entry or heard before the swap, or a +/// jump) the repositioned cell holds that ramp again, and the adoption rule compares the curve +/// where the ramp ends, where the stage's target was taken (U3 fold 2, D15-7): an unmoved target +/// keeps the edit's own length, and only a moved one lands over the jump length. /// Then, for a reposition or [`Adoption::Compare`], the adoption rule: when -/// the curve at the block's first sample, composed with this plan's `S` +/// the curve at the block's first sample (or that ramp's end), composed with this plan's `S` /// (`checked_fader_gain(vca_compose_db(curve, S))`, #1648 departure 4), differs in bits from the -/// gain the stage aims for, the block opens with a jump (returns `true`). Otherwise a repositioned +/// gain the stage aims for, the block opens with a jump (returns its length: `J`, or the frames +/// the free stage's own ramp has left when that is longer, so a running edit still ends when it +/// was due, on this table's curve; never shorter than `J`, so a moved target never lands faster +/// than a table change does). Otherwise a repositioned /// cell takes the curve's value as its current target (`seed_target`: the stage already holds its /// gain), and a continued one keeps its carried state. A block with a seek step lands the seek /// instead (the caller's step wins): the seek sets the lane exactly where it applies. @@ -1663,37 +1732,50 @@ fn settle_adoption( adoption: Adoption, block: AdoptionBlock<'_, '_>, state: &mut automation::CellState, -) -> bool { +) -> Option { let AdoptionBlock { program, span, offsets_sum, hold, + jump, } = block; if adoption == Adoption::None { - return false; + return None; } + // The landing's length: `J`, or the frames the free stage's own ramp has left when longer. + let mut length = jump; + // The node time at which the curve is compared: the block's first sample, or the end of a + // ramp the free stage still runs (U3 fold 2). + let mut at = span.start; if adoption == Adoption::Reposition { state.reposition(program, span.start); if let Some(ChannelHold { - held: true, - remaining, - .. + held, remaining, .. }) = hold && let Some(last) = remaining.checked_sub(1) && let Some(end) = node_time_at(span, u64::from(last)) { + // A held stage: the held ramp again (batch 13b). A free stage: the ramp it runs is the + // cell's own (a live level edit over its own length, or a jump) and `reposition` + // dropped its hold, so the cell holds it again and compares the curve where it ends + // (U3 fold 2, D15-7): the edit keeps its own length unless the new table moved its + // target. state.hold_until(end); + if !held { + at = end; + length = length.max(remaining); + } } } - let value = automation::value_at(program.segments(), span.start); + let value = curve_toward(program, span.start, at); let differs = FaderEventDriver::gain(value, offsets_sum) .zip(hold.and_then(|hold| hold.fader_gain)) .is_some_and(|(gain, stage)| gain.to_bits() != stage.to_bits()); if !differs && adoption == Adoption::Reposition { state.seed_target(value); } - differs + differs.then_some(length) } /// The cut of a block of `frames` samples whose offset 0 is node time `start`, with seek step diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index ea74d19cd..69676130d 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -8940,7 +8940,12 @@ mod set_control { /// Test value: red if a hand move's offsets write misses the carry drain (checked once) /// or its record loses its ramp word across the swap: the successor then jumps over the /// fader length from its prepared `S`. A hand move reaches the offsets cell through the - /// fader row's stored offset, not the VCA row, which the VCA forms do not exercise. + /// fader row's stored offset, not the VCA row, which the VCA forms do not exercise. The + /// rigs are C ABI plans (the shipped preparer, live cells on every strip): with the carry + /// drain's read removed, a debug build stops first at the B17-R12 `debug_assert` (the + /// adoption backstop becomes reachable; a panic in render aborts the test process, red), + /// and a release build reaches this test's bit assert (red at the moving ride's first + /// block after the swap). #[test] fn a_hand_move_pending_at_a_swap_keeps_its_length() { bench_support::alloc::assert_installed(); @@ -8982,7 +8987,10 @@ mod set_control { /// /// Test value: red if the join writes no offsets record for a hand move's stored offset /// (the successor's `S` then reaches the lane by the adoption rule, over the fader length - /// `J`, not over 4,096 samples). + /// `J`, not over 4,096 samples). The rigs are C ABI plans (the shipped preparer): with the + /// join's offsets write removed, a debug build stops first at the B17-R12 `debug_assert` + /// (a panic in render aborts the test process, red), and a release build reaches this + /// test's bit assert. #[test] fn a_hand_move_inside_a_rebuild_keeps_its_own_length() { bench_support::alloc::assert_installed(); @@ -9002,6 +9010,201 @@ mod set_control { } } + /// When a live level move reaches a swap whose rebuild changes the ride's table: still + /// unread, in the rebuild's own transaction, or heard some blocks before it. + #[derive(Clone, Copy, Debug)] + enum MoveTiming { + /// The move is committed live and the rebuild follows with no block between them. + Pending, + /// The move is in the rebuild's transaction. + SameTransaction, + /// The move is heard for this many blocks before the rebuild. + Heard(usize), + } + + /// The ride of [`riding`] split into two linear segments at `at` that join where the + /// single segment passes (equal values up to `f32` rounding), the second ending at + /// `end_db` (-12 dB keeps the ride; another value changes only the second segment), with + /// the stored `offset` (a hand move's, which the upsert must keep). + fn split_ride( + track: &str, + (at, end_db): (u64, f32), + offset: session::AutomationOffset, + ) -> SessionEdit { + let lin = session::AutomationShape::Linear; + let k = -12.0 * at as f32 / SOURCE_FRAMES as f32; + let mut automation = entry( + track, + vec![ + segment(lin, (0, at), (0.0, k)), + segment(lin, (at, SOURCE_FRAMES), (k, end_db)), + ], + ); + automation.offset = offset; + SessionEdit::UpsertAutomation { automation } + } + + /// U3 fold 2 (#1648 D2, D15-7). A live level move with its own 4,000 samples on the + /// riding fader (a VCA move and a hand move), and a rebuild whose only change is the + /// ride's table: the same ride split in two at sample 10,000, or a change only in its + /// last 2,000 samples. The move is pending at the swap, in the rebuild's transaction, or + /// heard 1 and 12 blocks before it. Every block of the rebuilt rig stays within 1e-3 of + /// the uninterrupted engine's (the split alone moves the output by at most 1.7e-4) over + /// 44 blocks, allocation-free, and the successor is adopted. + /// + /// Test value: red if the repositioned cell drops the hold of the ramp the free stage + /// still runs, or compares the curve at the block's first sample rather than at that + /// ramp's end with the stage's target: on a moving ride they differ, and the adoption + /// jump replaces the edit's 4,000 samples with the fader length (960; 0.047 at HEAD). + #[test] + fn a_level_move_across_a_table_change_keeps_its_length() { + bench_support::alloc::assert_installed(); + let document = riding(); + let track = first_track(&document); + let moves = [ + ("a VCA move", vca_move(-6.0, Some(4_000))), + ("a hand move", hand_move(&document, -6.0, Some(4_000))), + ]; + let changes = [ + ("the ride split at 10,000", (10_000, -12.0)), + ( + "a change in the last 2,000 samples", + (SOURCE_FRAMES - 2_000, -6.0), + ), + ]; + let timings = [ + MoveTiming::Pending, + MoveTiming::SameTransaction, + MoveTiming::Heard(1), + MoveTiming::Heard(12), + ]; + for (what, edit) in &moves { + for &(change, table) in &changes { + for timing in timings { + let label = &format!("{what}, {change}, {timing:?}"); + let mut rigs = warm(&document); + send( + &mut rigs[1], + core::slice::from_ref(edit), + protocol::EditPath::Live, + label, + ); + // The offset the move stored (a hand move's; zero for a VCA move). + let offset = rigs[1].model().automation[0].offset; + let rebuild = &split_ride(&track, table, offset); + if let MoveTiming::SameTransaction = timing { + send( + &mut rigs[0], + &[edit.clone(), rebuild.clone()], + protocol::EditPath::Rebuild, + label, + ); + } else { + send( + &mut rigs[0], + core::slice::from_ref(edit), + protocol::EditPath::Live, + label, + ); + if let MoveTiming::Heard(blocks) = timing { + let (first, _) = lockstep(&mut rigs, blocks); + assert_eq!(first, None, "{label}: before the rebuild"); + } + send( + &mut rigs[0], + core::slice::from_ref(rebuild), + protocol::EditPath::Rebuild, + label, + ); + } + let [a, u, _] = &mut rigs; + let mut worst = (0.0_f32, 0_u64); + for _ in 0..44 { + let block = a.block; + let (x, used) = a.step_counted(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "{label}: block {block} allocated or freed" + ); + for (p, q) in x.iter().zip(&u.step()) { + if (p - q).abs() > worst.0 { + worst = ((p - q).abs(), block); + } + } + } + assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); + assert!( + worst.0 < 1e-3, + "{label}: max |a - u| {} at block {}", + worst.0, + worst.1 + ); + } + } + } + } + + /// U3 fold 2, MINOR (#1648 D2, D15-2). Two live VCA moves around a rebuild, with no block + /// between any of them: a move pending at a rebuild whose own transaction moves `v` + /// again, and a move, a rebuild, then a second live move. The first move is replaced + /// unheard in both, as two writes before one uninterrupted block are, so + /// `LIVE_VALUES_SUPERSEDED` equals the uninterrupted engine's, and every block does too. + /// + /// Test value: red if the successor's first block, applying a record of its own over the + /// carried one, does not count the carried record it replaces unheard. + #[test] + fn a_carried_move_replaced_unheard_counts_as_superseded() { + bench_support::alloc::assert_installed(); + for document in [riding(), flat()] { + let model = parse_session_json(&document).expect("model"); + let rebuild = add_muted_track(&model, "added"); + for (label, inside) in [ + ("a rebuild that moves again", true), + ("a move, a rebuild, a move", false), + ] { + let mut rigs = warm(&document); + let (first_move, second) = (vca_move(-6.0, None), vca_move(-3.0, Some(4_000))); + for rig in &mut rigs[..2] { + send( + rig, + core::slice::from_ref(&first_move), + protocol::EditPath::Live, + label, + ); + } + if inside { + let mut edits = vec![second.clone()]; + edits.extend(rebuild.iter().cloned()); + send(&mut rigs[0], &edits, protocol::EditPath::Rebuild, label); + } else { + send(&mut rigs[0], &rebuild, protocol::EditPath::Rebuild, label); + send( + &mut rigs[0], + core::slice::from_ref(&second), + protocol::EditPath::Live, + label, + ); + } + send( + &mut rigs[1], + core::slice::from_ref(&second), + protocol::EditPath::Live, + label, + ); + let (first, heard) = lockstep(&mut rigs, 44); + assert_eq!(rigs[0].summary().3, 0, "{label}: the successor is adopted"); + assert_eq!(first, None, "{label}: first difference (block, sample)"); + assert!(heard, "{label}: the moves are heard"); + let counts = [superseded(&mut rigs[0]), superseded(&mut rigs[1])]; + assert_eq!( + counts[0], counts[1], + "{label}: LIVE_VALUES_SUPERSEDED [a, u]" + ); + } + } + } + /// U3 fold, MAJOR 2 (R1). A live VCA move heard (mid-jump after three blocks, settled /// after twelve), then a rebuild that keeps the ride: the carried lane continues with no /// adoption jump, and every block equals the uninterrupted engine's. diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index 6472a5bf4..8924a4be6 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -2033,12 +2033,28 @@ fn a_live_write_that_frees_a_held_lane_releases_its_hold() { } } -/// A rig of `model` at `quantum` with live controls and its plan's gate, on one of the five -/// render forms [`acted`] compares: the live bank at eight and four lanes, the same between -/// render calls (the fused path), and the scalar oracle (R17-4). +/// The render forms [`acted`] compares, as `(backend, between render calls)`: the live bank at +/// eight lanes (on an AVX2 target only, the one that has them) and four, the same between render +/// calls (the fused path), and the scalar oracle (R17-4). #[cfg(feature = "test-support")] -fn form_rig(model: &SessionModel, quantum: u32, form: usize) -> Rig { - use graph_compiler::Backend; +const FORMS: &[(graph_compiler::Backend, bool)] = &[ + #[cfg(target_feature = "avx2")] + (graph_compiler::Backend::Simd8, false), + (graph_compiler::Backend::Simd4, false), + #[cfg(target_feature = "avx2")] + (graph_compiler::Backend::Simd8, true), + (graph_compiler::Backend::Simd4, true), + (graph_compiler::Backend::Scalar, false), +]; + +/// A rig of `model` at `quantum` with live controls and its plan's gate, on one of the +/// [`FORMS`]. +#[cfg(feature = "test-support")] +fn form_rig( + model: &SessionModel, + quantum: u32, + (backend, between): (graph_compiler::Backend, bool), +) -> Rig { let mut model = model.clone(); model.quantum_frames = quantum; let document = canonical_session_json(&model).expect("canonical"); @@ -2046,13 +2062,6 @@ fn form_rig(model: &SessionModel, quantum: u32, form: usize) -> Rig { let compiled = host_core::compile_host_session(&document, &caps) .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); let request = host_core::HostLiveControlRequest::default(); - let (backend, between) = match form { - 0 => (Backend::Simd8, false), - 1 => (Backend::Simd4, false), - 2 => (Backend::Simd8, true), - 3 => (Backend::Simd4, true), - _ => (Backend::Scalar, false), - }; let (mut prepared, handles) = if between { host_core::prepare::test_only_prepare_host_runtime_between_render_calls_on( &compiled, &caps, &request, backend, @@ -2094,7 +2103,7 @@ const fn act(at: u64) -> Act { } /// The output of `model` at `quantum` over `frames` with `acts`, the same bits on each of the -/// five forms of [`form_rig`]; and the block entry each act landed on. +/// [`FORMS`] the target provides (five on AVX2); and the block entry each act landed on. #[cfg(feature = "test-support")] fn acted( model: &SessionModel, @@ -2105,7 +2114,7 @@ fn acted( use builtins_compiler::TestOnlyWriteGate; let mut outs = Vec::new(); let mut entries = Vec::new(); - for form in 0..5 { + for &form in FORMS { let mut rig = form_rig(model, quantum, form); entries.clear(); while rig.rendered < frames { From 1bd0494f5d507ace7e024268ad2182e89f543e2e Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 23:23:27 +0000 Subject: [PATCH 29/35] Jump over J at a step at a Reposition swap's first sample (#1648, B17-R13) settle_adoption no longer moves the comparison to the end of a free stage's running ramp when the cell's own table has a discontinuity at the block's first sample: the lane jumps over J at the step, as a plan prepared with that table does. Gate: a_step_at_a_reposition_swap_jumps_over_the_fader_length (VCA and hand move, heard 1 and 12, step at the swap +0/+1, against a fresh plan). Co-Authored-By: Claude Opus 5.5 --- crates/builtins-compiler/src/lib.rs | 11 ++- crates/capi/src/runtime/live_tests.rs | 128 ++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 7f9acb477..db6adb3e2 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -1762,7 +1762,16 @@ fn settle_adoption( // (U3 fold 2, D15-7): the edit keeps its own length unless the new table moved its // target. state.hold_until(end); - if !held { + // A step of the cell's own at the block's first sample jumps over `J` there, as the + // cell does at any discontinuity (and as a plan prepared with this table does), so + // the curve is compared at the block's first sample and the landing is `J` + // (B17-R13): `curve_toward` looks only past `start` and would skip it. + if !held + && program + .discontinuities() + .binary_search(&span.start) + .is_err() + { at = end; length = length.max(remaining); } diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 69676130d..016e7025a 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -9145,6 +9145,134 @@ mod set_control { } } + /// [`riding`]'s ride kept up to `at`, then a step to `db` from `at` to the source's end, + /// with the stored `offset`. + fn stepped_ride( + track: &str, + at: u64, + db: f32, + offset: session::AutomationOffset, + ) -> session::Automation { + let k = -12.0 * at as f32 / SOURCE_FRAMES as f32; + let mut automation = entry( + track, + vec![ + segment(session::AutomationShape::Linear, (0, at), (0.0, k)), + segment( + session::AutomationShape::Step, + (at, SOURCE_FRAMES), + (db, db), + ), + ], + ); + automation.offset = offset; + automation + } + + /// B17-R13 (#1648 D2). A live level move with its own 4,000 samples (a VCA move and a + /// hand move) heard 1 and 12 blocks, then a rebuild whose ride steps to -20 dB exactly + /// at the swap block's first sample, or one sample after it (inside the landing block). + /// The rebuilt rig is compared with a fresh plan prepared with the stepped ride from the + /// start and given the same move at the same block: at the step the lane jumps over the + /// fader length `J` in both, so from the step plus `J` on every sample stays within 1e-3 + /// of the fresh plan's (measured: 7.8e-5 heard 1, 5.2e-4 heard 12) over 44 blocks, every + /// block is allocation-free, and the successor is adopted. + /// + /// Test value: red if the adoption skips a step at the block's first sample (it compares + /// the curve where the move's ramp ends and lands over the ramp's remaining length, not + /// over `J` at the step; 8.6e-2, checked once), or if a long landing runs over the whole + /// block, step included (the step one sample after the swap; 8.6e-2, checked once). + #[test] + fn a_step_at_a_reposition_swap_jumps_over_the_fader_length() { + /// The session's fader jump length `J`. + const FADER_JUMP: u64 = 960; + bench_support::alloc::assert_installed(); + let document = riding(); + let track = first_track(&document); + for (what, hand) in [("a VCA move", false), ("a hand move", true)] { + for heard in [1_usize, 12] { + for delta in [0_u64, 1] { + let label = &format!("{what}, heard {heard}, a step at the swap + {delta}"); + let edit_of = |document: &str| { + if hand { + hand_move(document, -6.0, Some(4_000)) + } else { + vca_move(-6.0, Some(4_000)) + } + }; + let mut a = Rig::new(&document); + for _ in 0..4 { + a.step(); + } + send( + &mut a, + core::slice::from_ref(&edit_of(&document)), + protocol::EditPath::Live, + label, + ); + let swap_block = a.block + heard as u64; + let at = swap_block * a.quantum as u64 + delta; + let offset = a.model().automation[0].offset; + let stepped = stepped_ride(&track, at, -20.0, offset); + // The fresh plan: the stepped ride from the start, the same move. + let mut model = parse_session_json(&document).expect("model"); + model.automation[0] = + stepped_ride(&track, at, -20.0, session::AutomationOffset::default()); + let fresh = session::canonical_session_json(&model).expect("canonical"); + let mut r = Rig::new(&fresh); + for _ in 0..4 { + r.step(); + } + send( + &mut r, + core::slice::from_ref(&edit_of(&fresh)), + protocol::EditPath::Live, + label, + ); + // Before the swap the fresh plan differs already (its move aims short of + // the step, the rule of the cell's own target), so only the samples from + // the step's jump completion on are compared. + for _ in 0..heard { + a.step(); + r.step(); + } + send( + &mut a, + &[SessionEdit::UpsertAutomation { + automation: stepped, + }], + protocol::EditPath::Rebuild, + label, + ); + let mut worst = (0.0_f32, 0_u64); + for _ in 0..44 { + let block = a.block; + let (x, used) = a.step_counted(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "{label}: block {block} allocated or freed" + ); + let start = block * a.quantum as u64; + for (i, (p, q)) in x.iter().zip(&r.step()).enumerate() { + let d = (p - q).abs(); + if start + i as u64 >= at + FADER_JUMP && d > worst.0 { + worst = (d, block); + } + } + } + assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); + assert!( + worst.0 < 1e-3, + "{label}: max |a - r| from the step plus J {} at block {}", + worst.0, + worst.1 + ); + } + } + } + } + /// U3 fold 2, MINOR (#1648 D2, D15-2). Two live VCA moves around a rebuild, with no block /// between any of them: a move pending at a rebuild whose own transaction moves `v` /// again, and a move, a rebuild, then a second live move. The first move is replaced From 5fa5dd54eee268a9a7ea22c62bba81610d0e2359 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 23:28:06 +0000 Subject: [PATCH 30/35] Correct the carry gate's tolerance note and the D2 text (#1648 NITs, B17-R13) Co-Authored-By: Claude Opus 5.5 --- .../1648-classify-and-carry-fader-automation-edits.md | 6 +++++- crates/capi/src/runtime/live_tests.rs | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index ddc96a3f7..21b9f3744 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -75,7 +75,11 @@ Today every automation edit is "live with no records": it commits with no plan c them by address. No second carry program exists for cell state. - **At adoption.** The successor repositions each carried cursor in its own table (#1637 D4). If the new curve's value at the adoption block's first sample differs in bits from the carried - current target, the cell takes a jump there, over the fader jump length. + current target, the cell takes a jump there, over the fader jump length. (Amended by U3 fold + 2 and B17-R13: on a free stage still in a ramp, the curve is compared where that ramp ends and + the landing is over `max(J, remaining)`; a step of the cell's own at the first sample keeps + the comparison there and the landing over `J`. See "U3 fold 2" and "Split (root ruling + B17-R13)".) - **A cell gained.** A lane that gains automation on a carried stage starts its cell with the carried stage target as its current target, then applies the adoption rule above. It is not set exactly: the strip is not added (A1.4 "New lanes"). diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 016e7025a..3c17f272d 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -9049,8 +9049,10 @@ mod set_control { /// ride's table: the same ride split in two at sample 10,000, or a change only in its /// last 2,000 samples. The move is pending at the swap, in the rebuild's transaction, or /// heard 1 and 12 blocks before it. Every block of the rebuilt rig stays within 1e-3 of - /// the uninterrupted engine's (the split alone moves the output by at most 1.7e-4) over - /// 44 blocks, allocation-free, and the successor is adopted. + /// the uninterrupted engine's over 44 blocks, allocation-free, and the successor is + /// adopted. Measured: 0 pending, in the transaction and heard 1; 4.6e-6 heard 12, where + /// the landing over the rest of the move's ramp recomputes the stage's ramp step at the + /// swap (B17-R13, NIT 5). /// /// Test value: red if the repositioned cell drops the hold of the ramp the free stage /// still runs, or compares the curve at the block's first sample rather than at that From 6cea893761086baff1a49f6bd4f0ce66f181779c Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 23:35:53 +0000 Subject: [PATCH 31/35] Split #1648's Reposition stretch into TBD50 (B17-R13) TBD50 holds the deferred-jump design that keeps a live level move's own length across a Reposition swap; #1648 records the split and its attempt count. Co-Authored-By: Claude Opus 5.5 --- ...assify-and-carry-fader-automation-edits.md | 35 +++++++++ ...ves-own-length-across-a-reposition-swap.md | 71 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 .github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index 21b9f3744..609e7c22d 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -721,3 +721,38 @@ Also in this attempt (coordinator): `crates/host-core/tests/stored_fader_automat `Backend::Simd8` in `form_rig` on every target; the forms are now a `FORMS` list whose eight-lane entries carry `cfg(target_feature = "avx2")`, so `acted` compares five forms on x86-64 and three elsewhere. `scripts/check-cross-targets.sh` passes. + +## Split (root ruling B17-R13, batch 17) + +- #1648 closes on its passing gates. The in-flight Reposition length case moves to + `TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md`, following the "split a + blocking gate early" practice (a gate that holds a passed batch goes to a successor after one + fix round). +- Current difference (verifier, at 0fac00ee9): a live level move still ramping with + `remaining < J` at a Reposition swap is stretched to `J` by the landing `max(J, remaining)`. + Gate setup: heard 24, 26 and 30 blocks (928, 672 and 160 samples left; `J` = 960). The rebuilt + rig differs from the uninterrupted engine by 4.8e-4, 3.1e-3 and 2.1e-3. The watermark marks it + as a fallback under root ruling B17-R14 (control-time, conservative), which is not landed yet in + this record: see its decision record when it lands. TBD50 replaces that flag with the exact + behaviour. +- The step guard (regression fix, `settle_adoption` in `crates/builtins-compiler/src/lib.rs`): + when the cell's program has a discontinuity exactly at the block's first sample + (`program.discontinuities().binary_search(&span.start)` finds it), the curve is compared there + and the landing is `J`. `curve_toward` looks only past `start`, so before the guard the landing + ran over `remaining` and missed the step's `J` jump (8.6e-2 from a fresh plan). +- Gate `a_step_at_a_reposition_swap_jumps_over_the_fader_length` + (`crates/capi/src/runtime/live_tests.rs`, `automated_carry`): VCA and hand moves of 4,000 + samples, heard 1 and 12 blocks, then a rebuild whose ride steps to -20 dB at the swap's first + sample or one sample after it. The reference is a fresh plan prepared with the stepped ride and + given the same move. From the step plus `J`, max |a - r| < 1e-3 (measured: 7.8e-5 heard 1, + 5.2e-4 heard 12), allocation-free, adopted. Red without the guard (8.6e-2, step at the swap). + Red when the long landing runs over the whole block (verifier's mutation m5: 8.6e-2, step one + sample after the swap; this is MINOR 3). Each was checked once. +- MINOR 4 (`at = end` has no gate) moves to TBD50. Probe finding: for every heard move on the + moving ride, the stage's `fader_gain` at the swap is not the composed curve at the ramp's end + (0.398 at 12 blocks heard, 0.335 at 24, against 0.3018). The comparison therefore differs at + either point, and `at` cannot be seen by the current tables. +- NIT 5: the doc of `a_level_move_across_a_table_change_keeps_its_length` now gives the measured + numbers (0, and 4.6e-6 at 12 blocks heard, where the landing recomputes the stage's ramp step). + NIT 6: D2 has a note that points to the fold's longer landing and the step rule. +- Attempt count: attempt 3 of 5 plus this ruling's landing. There is no attempt 4. diff --git a/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md b/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md new file mode 100644 index 000000000..107dbfb9d --- /dev/null +++ b/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md @@ -0,0 +1,71 @@ +# Keep a live level move's own length across a Reposition swap + +Placeholder from batch 17 (#1648's split, root ruling B17-R13; controls coordinator, 2026-10-16). +It follows the "split a blocking gate early" practice: #1648 closes on its passing gates, and the +in-flight length case moves here. + +## Product outcome + +A live level move (a VCA move over an automated fader, or a hand move stored as the ride's offset) +that is still ramping when a `Reposition` swap adopts (a rebuild whose only change on that lane is +the ride's table) ends when it was due, with its own length, for every `remaining` (the frames +left in its ramp at the swap), including `remaining < J` (`J`, the fader jump length, 960 in the +gate's session). The revision's watermark then reports `OUTCOME_EXACT`. This issue replaces the +conservative fallback flag of B17-R14 with the exact behaviour and deletes that flag path. + +## Context + +- Since #1648 attempt 3 (U3 fold 2), the adoption rule (`settle_adoption`, + `crates/builtins-compiler/src/lib.rs`) holds a free stage's running ramp again, compares the + curve where that ramp ends with the stage's target, and lands a moved target over + `max(J, remaining)` (a one-sample landing piece, then the rest of the block over `J`). +- The defect (verifier, batch 17, at 0fac00ee9): when `remaining < J` the landing is stretched to + `J`. In the gate's setup (a 4,000-sample move from block 4, `a_level_move_across_a_table_change_ + keeps_its_length` in `crates/capi/src/runtime/live_tests.rs`) a move heard 24, 26 and 30 blocks + has 928, 672 and 160 samples left; the rebuilt rig differs from the uninterrupted engine by + 4.8e-4, 3.1e-3 and 2.1e-3 (both table changes, VCA and hand moves). +- Current behaviour (B17-R13, and B17-R14 once landed): stretched to `J`; the rebuild's candidate is marked + `CandidateOutcome::TransitionFallback` on the control thread when it Reposition-adopts an + automated lane with a live level move that may still ramp, so the watermark reports + `OUTCOME_TRANSITION_FALLBACK`, conservatively (it can be set when the move was not stretched). +- Found while landing B17-R13 (measured with a temporary probe): for every heard move on the moving + ride the stage's `fader_gain` at the swap is not the composed curve at the ramp's end (0.398 at + 12 blocks heard and 0.335 at 24, against 0.3018), so the adoption rule's comparison always + differs and every heard move lands at the swap (over `remaining`, or `J`). The 4.6e-6 at 12 + blocks heard comes from that landing recomputing the stage's ramp step. Find why the stage's + target moves during the move before designing the comparison. +- Also from B17-R13 (MINOR 4, not gated): the comparison point `at = end` in `settle_adoption` has + no gate, because of the finding above (the comparison differs at either point). A gate needs a + heard move whose stage target equals the curve at its ramp's end. + +## Decision to make at filing + +- D1. The deferred jump. At a `Reposition` adoption, a free stage's live ramp is not retargeted: + it runs to its end as the uninterrupted engine runs it. The cell makes the D2 comparison and, if + the target moved, the `J` jump at the first free sample after the ramp (the ramp's end), not at + the block's first sample. A small API in `crates/automation` gives the cell's deferred jump + (the node time at which it is due and the comparison it makes there), so the driver needs no + second landing path; the one-sample landing piece and `max(J, remaining)` are deleted. +- D2. The conservative fallback flag of B17-R14 (control-time `TransitionFallback` on such a + rebuild) is deleted; the C header comment, `docs/C_ABI_V1_QUALIFICATION.md` and the d.ts text + that B17-R14 appended are removed. +- D3. A step of the cell's own at the swap block's first sample keeps the B17-R13 rule: the curve + is compared there and the landing is over `J` (gate + `a_step_at_a_reposition_swap_jumps_over_the_fader_length`). + +## Objective gates + +- G1. VCA and hand moves of 4,000 samples, heard 1, 12, 24, 26 and 30 blocks before a table-only + rebuild (the ride split at 10,000, and a change in its last 2,000 samples): every block after + the swap is bit-equal to the uninterrupted engine's (or, where the table's own values differ, + to a fresh plan prepared with the new table and given the same move), allocation-free, adopted, + and the rebuild's watermark advance reports `OUTCOME_EXACT`. +- G2. The comparison point: a heard move whose stage target equals the curve at its ramp's end + keeps its own length with less than `J` left (red if the curve is compared at the block's first + sample). +- G3. The B17-R13 step gate and the #1648 gates stay green; realtime policy count unchanged or + explained. + +## Dependencies + +- #1648 (closed on its gates), B17-R13 and B17-R14 (the conservative flag this issue removes). From d5c85be94920567aa578ce216a99aa81e690f1fa Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 00:00:42 +0000 Subject: [PATCH 32/35] Report a Reposition swap during a level ramp as a fallback (#1648, B17-R14) A rebuild transaction whose successor automates a fader lane completes as CandidateOutcome::TransitionFallback while any level move may still ramp (one conservative window on control: newest revision, longest ramp; live FaderDb/offsets records and rebuild level changes). Control cannot see which lanes reposition or how much ramp is left, so the flag over-reports; TBD50 replaces it with the exact behaviour and deletes it. No render or realtime edit. Gates: a_reposition_swap_while_a_level_move_ramps_reports_a_fallback (capi), a_reposition_swap_while_a_level_move_ramps_reports_the_fallback_alike_on_both_hosts (host-web). Co-Authored-By: Claude Opus 5.5 --- ...assify-and-carry-fader-automation-edits.md | 54 ++++++- ...ves-own-length-across-a-reposition-swap.md | 32 +++- crates/capi/include/miso_engine_v1.h | 5 +- crates/capi/src/abi.rs | 3 +- crates/capi/src/runtime/live_tests.rs | 80 ++++++++++ crates/control-plane/src/compile.rs | 1 + crates/control-plane/src/control.rs | 150 ++++++++++++++++++ crates/control-plane/src/plan.rs | 9 ++ docs/C_ABI_V1_QUALIFICATION.md | 4 +- hosts/host-web/tests/control_parity.rs | 81 ++++++++++ .../miso-engine-v1-audio-worklet-host.d.ts | 4 +- sdk/src/browser/shipped-host.d.ts | 4 +- 12 files changed, 412 insertions(+), 15 deletions(-) diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index 609e7c22d..fcd23d560 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -732,9 +732,8 @@ elsewhere. `scripts/check-cross-targets.sh` passes. `remaining < J` at a Reposition swap is stretched to `J` by the landing `max(J, remaining)`. Gate setup: heard 24, 26 and 30 blocks (928, 672 and 160 samples left; `J` = 960). The rebuilt rig differs from the uninterrupted engine by 4.8e-4, 3.1e-3 and 2.1e-3. The watermark marks it - as a fallback under root ruling B17-R14 (control-time, conservative), which is not landed yet in - this record: see its decision record when it lands. TBD50 replaces that flag with the exact - behaviour. + as a fallback under root ruling B17-R14 (control-time, conservative; see the next point). TBD50 + replaces that flag with the exact behaviour and deletes this path. - The step guard (regression fix, `settle_adoption` in `crates/builtins-compiler/src/lib.rs`): when the cell's program has a discontinuity exactly at the block's first sample (`program.discontinuities().binary_search(&span.start)` finds it), the curve is compared there @@ -756,3 +755,52 @@ elsewhere. `scripts/check-cross-targets.sh` passes. numbers (0, and 4.6e-6 at 12 blocks heard, where the landing recomputes the stage's ramp step). NIT 6: D2 has a note that points to the fold's longer landing and the step rule. - Attempt count: attempt 3 of 5 plus this ruling's landing. There is no attempt 4. + +## Decision record: the conservative fallback flag (root ruling B17-R14, batch 17) + +- Mechanism: the control plane sets `CandidateOutcome::TransitionFallback` on a rebuild's + reservation before it publishes it (the existing writer, `ObservedReservation::set_outcome` in + `crates/control-plane/src/plan.rs`). Render is not touched, and nothing under + `crates/engine/src/realtime/` changed. The flag is reported on the watermark advance that covers + the rebuild's revision: the swap where the stretch happens. Both hosts take the decision in the + shared `control-plane` rebuild path. +- Exact condition: the rebuild is a transaction (not a declared discontinuity), the successor's model has at least + one stored fader automation entry (`controls::automation_row_renders`), and the session's level + move window may still ramp. The window (`LevelMoveWindow` in + `crates/control-plane/src/control.rs`) folds every live commit's fader `FaderDb` and offsets + records with a nonzero ramp, and every rebuild commit that changes some kept strip's effective + fader dB or `S` (`controls::effective`, old model against new) with the ramp + `max(fader_samples, the longest explicit ramp of the transaction)`, into one bound: the newest + revision and the longest ramp. "May still ramp" is `watermark.revision < revision || + render_clock < watermark.first_sample + ramp` (a watermark read that gives up counts as "may"). + The watermark and the render clock are read before the reservation. +- Departure from the coordinator's per-strip table: one window for the whole session, not one entry + per strip. It is a superset of the per-strip test, so it can only over-report. It is two words, so + it is bounded with no allocation, and it needs no strip IDs. The per-lane "table changes" test was + dropped (coordinator decision): a moved input arrival `a(n)` also Repositions, and control cannot + see `a(n)`. +- Over-report cases: any transaction rebuild of a session with fader automation while any level + move may still ramp, on any strip: a move with more than `J` left, a move whose target the new + table did not move, a rebuild that Repositions no lane, a move on a strip that has no automation. + After a newer revision, the window also over-reports because the newest revision is paired with + the longest ramp. A rebuild while a move is still pending (no block has heard it) is flagged too. +- Not flagged (not a live level move): a cell's own jump of `J` that is still running at a + Reposition swap is extended in the same way. This is read from the code, not measured (TBD50 + context). +- Gates: `a_reposition_swap_while_a_level_move_ramps_reports_a_fallback` + (`crates/capi/src/runtime/live_tests.rs`): a live VCA move heard 24, 26 and 30 blocks, and a + move inside a rebuild heard 12 blocks, report `OUTCOME_TRANSITION_FALLBACK` (count 1) on the + table-only rebuild. No move, and a move whose ramp has ended (heard 40 blocks), report + `OUTCOME_EXACT`. Red with the `set_outcome` line removed (heard 24 reads EXACT), and red with + the rebuild path's fold removed (the move inside a rebuild reads EXACT). Each was checked once. + `a_reposition_swap_while_a_level_move_ramps_reports_the_fallback_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`): at all four rates, the browser's status words equal + the C ABI's watermark, flags included. A hand move in flight flags, no move is exact, and the + bits are the same on both hosts. No gap-1 case: the rigs have no cheap way to move `a(n)` + alone, and with the wide condition no lane test is left to defend. +- Doc text appended to the flag: `crates/capi/src/abi.rs` (doc of `OUTCOME_TRANSITION_FALLBACK`), + the C header comment (`crates/capi/include/miso_engine_v1.h`, watermark section), + `docs/C_ABI_V1_QUALIFICATION.md`, `hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts` + and its SDK copy `sdk/src/browser/shipped-host.d.ts`. The doc of `watermark.rs` under + `crates/engine/src/realtime/` is not edited. `scripts/check-capi-abi.sh` and + `scripts/check-sdk-generated.sh` pass, and no pin moved. diff --git a/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md b/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md index 107dbfb9d..c2452898e 100644 --- a/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md +++ b/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md @@ -24,10 +24,24 @@ conservative fallback flag of B17-R14 with the exact behaviour and deletes that keeps_its_length` in `crates/capi/src/runtime/live_tests.rs`) a move heard 24, 26 and 30 blocks has 928, 672 and 160 samples left; the rebuilt rig differs from the uninterrupted engine by 4.8e-4, 3.1e-3 and 2.1e-3 (both table changes, VCA and hand moves). -- Current behaviour (B17-R13, and B17-R14 once landed): stretched to `J`; the rebuild's candidate is marked - `CandidateOutcome::TransitionFallback` on the control thread when it Reposition-adopts an - automated lane with a live level move that may still ramp, so the watermark reports - `OUTCOME_TRANSITION_FALLBACK`, conservatively (it can be set when the move was not stretched). +- Current behaviour (B17-R13 and B17-R14): the move is stretched to `J`. The control plane marks a + rebuild candidate `CandidateOutcome::TransitionFallback` before it publishes it when the + rebuild is a transaction (not a declared discontinuity), the successor's model has at least one + stored fader automation entry (`controls::automation_row_renders`), and the session's level + move window may still ramp. The window (`LevelMoveWindow` in + `crates/control-plane/src/control.rs`) folds every live commit's fader `FaderDb` and offsets + records with a nonzero ramp, and every rebuild commit that changes some kept strip's effective + fader dB or `S` (`controls::effective`, old model against new) with the ramp + `max(fader_samples, the longest explicit ramp of the transaction)`, into one bound: the newest + revision and the longest ramp. "May still ramp" is `watermark.revision < revision || + render_clock < watermark.first_sample + ramp` (a watermark read that gives up counts as "may"). + The watermark and the render clock are read before the reservation. + So the watermark reports `OUTCOME_TRANSITION_FALLBACK`. This is conservative: it is also set + when no move is stretched (any transaction rebuild of a session with fader automation while any + level move may still ramp; see the #1648 decision record "the conservative fallback flag"). +- Also stretched and not flagged: a cell's own jump of `J` that is still running at a Reposition + swap (`remaining < J`, landed over `J` again from the swap). This is read from the code, not + measured. - Found while landing B17-R13 (measured with a temporary probe): for every heard move on the moving ride the stage's `fader_gain` at the swap is not the composed curve at the ramp's end (0.398 at 12 blocks heard and 0.335 at 24, against 0.3018), so the adoption rule's comparison always @@ -46,9 +60,13 @@ conservative fallback flag of B17-R14 with the exact behaviour and deletes that the block's first sample. A small API in `crates/automation` gives the cell's deferred jump (the node time at which it is due and the comparison it makes there), so the driver needs no second landing path; the one-sample landing piece and `max(J, remaining)` are deleted. -- D2. The conservative fallback flag of B17-R14 (control-time `TransitionFallback` on such a - rebuild) is deleted; the C header comment, `docs/C_ABI_V1_QUALIFICATION.md` and the d.ts text - that B17-R14 appended are removed. +- D2. The conservative fallback flag of B17-R14 is deleted: `LevelMoveWindow`, `fold_level_move`, + `rebuild_level_ramp`, `rebuild_moves_a_level` and the `set_outcome` call in + `crates/control-plane/src/control.rs`, with `ObservedReservation::set_outcome` if nothing else + uses it. The text that B17-R14 appended is removed from `crates/capi/src/abi.rs`, the C header + comment, `docs/C_ABI_V1_QUALIFICATION.md` and the two d.ts files. Its gates + (`a_reposition_swap_while_a_level_move_ramps_reports_a_fallback` and its host-web parity test) + are replaced by G1. - D3. A step of the cell's own at the swap block's first sample keeps the B17-R13 rule: the curve is compared there and the landing is over `J` (gate `a_step_at_a_reposition_swap_jumps_over_the_fader_length`). diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index b29697944..0878b4e7d 100644 --- a/crates/capi/include/miso_engine_v1.h +++ b/crates/capi/include/miso_engine_v1.h @@ -178,7 +178,10 @@ extern "C" { * late still reads the newest revision, and the counters still count every revision. * outcome_flags is the OR * over the revisions the last advance covered of MISO_ENGINE_V1_OUTCOME_EXACT, - * MISO_ENGINE_V1_OUTCOME_TRANSITION_FALLBACK and MISO_ENGINE_V1_OUTCOME_SUPERSEDED. The host + * MISO_ENGINE_V1_OUTCOME_TRANSITION_FALLBACK and MISO_ENGINE_V1_OUTCOME_SUPERSEDED. + * MISO_ENGINE_V1_OUTCOME_TRANSITION_FALLBACK reports a warm successor's fallback to its + * transition, or a Reposition swap while a live level move may still ramp (conservative: it can be + * set when the move was not stretched; #TBD50 removes it). The host * polls the watermark to learn that an edit is audible, never the event lane. The caller zeroes * reserved0 and reserved[] and sets struct_size, or the call returns * MISO_ENGINE_V1_INVALID_ARGUMENT; a copy that keeps landing inside render's publication diff --git a/crates/capi/src/abi.rs b/crates/capi/src/abi.rs index f6c63743d..cc6aee23f 100644 --- a/crates/capi/src/abi.rs +++ b/crates/capi/src/abi.rs @@ -93,7 +93,8 @@ pub const FEATURE_MASK: u64 = FEATURE_IMMUTABLE_SESSION /// Watermark outcome flag: the covered revisions completed exactly as committed. pub const OUTCOME_EXACT: u64 = 1; /// Watermark outcome flag: a warm successor could not adopt, and the covered revisions completed -/// through the transition it fell back to. +/// through the transition it fell back to, or a Reposition swap while a live level move may still +/// ramp (conservative: it can be set when the move was not stretched; #TBD50 removes it). pub const OUTCOME_TRANSITION_FALLBACK: u64 = 2; /// Watermark outcome flag: the covered revisions include ones a newer candidate folded in when it /// replaced the candidate that carried them. diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 3c17f272d..b3c861f32 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -9275,6 +9275,86 @@ mod set_control { } } + /// The outcome flags and the transition-fallback count of `rig`'s watermark. + fn outcome(rig: &Rig) -> (u64, u64) { + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + (watermark.outcome_flags, watermark.transition_fallback_count) + } + + /// B17-R14 (#1648 split, TBD50). A table-only rebuild of the riding fader while a live + /// level move may still ramp completes as the transition fallback: a VCA move with its + /// own 4,000 samples heard 24, 26 and 30 blocks (928, 672 and 160 samples left, landed + /// over `J` = 960), and a VCA move committed inside a rebuild, heard 12 blocks, then a + /// table-only rebuild (the rebuild path's moves). The watermark advance that covers the + /// rebuild reports `OUTCOME_TRANSITION_FALLBACK`, not `OUTCOME_EXACT`. A table-only + /// rebuild with no move, and one after a move whose ramp has ended (heard 40 blocks), + /// report `OUTCOME_EXACT`. + /// + /// Test value: red if the control plane does not mark the candidate (the stretched move + /// then completes as exact, checked once with the `set_outcome` line removed), if a live + /// move or a rebuild's level change is not tracked, or if a rebuild with no move in + /// flight is marked (the flag must stay narrow). + #[test] + fn a_reposition_swap_while_a_level_move_ramps_reports_a_fallback() { + let fallback = crate::abi::OUTCOME_TRANSITION_FALLBACK; + let exact = crate::abi::OUTCOME_EXACT; + let document = riding(); + let model = parse_session_json(&document).expect("model"); + let track = first_track(&document); + let rebuild = split_ride(&track, (10_000, -6.0), session::AutomationOffset::default()); + let edit = vca_move(-6.0, Some(4_000)); + // (label, the move: none, live or inside a rebuild, blocks heard, expected flags) + let cases = [ + ("no move", None, 0, exact), + ("a live move heard 24", Some(false), 24, fallback), + ("a live move heard 26", Some(false), 26, fallback), + ("a live move heard 30", Some(false), 30, fallback), + ("a live move heard 40 (ended)", Some(false), 40, exact), + ("a move inside a rebuild heard 12", Some(true), 12, fallback), + ]; + for (label, inside, heard, expected) in cases { + let mut a = Rig::new(&document); + for _ in 0..4 { + a.step(); + } + match inside { + None => {} + Some(false) => send( + &mut a, + core::slice::from_ref(&edit), + protocol::EditPath::Live, + label, + ), + Some(true) => { + let mut edits = vec![edit.clone()]; + edits.extend(add_muted_track(&model, "added")); + send(&mut a, &edits, protocol::EditPath::Rebuild, label); + } + } + for _ in 0..heard { + a.step(); + } + send( + &mut a, + core::slice::from_ref(&rebuild), + protocol::EditPath::Rebuild, + label, + ); + for _ in 0..4 { + a.step(); + } + assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); + let (flags, fallbacks) = outcome(&a); + assert_eq!(flags, expected, "{label}: the rebuild's outcome flags"); + assert_eq!( + fallbacks, + u64::from(expected == fallback), + "{label}: transition-fallback count" + ); + } + } + /// U3 fold 2, MINOR (#1648 D2, D15-2). Two live VCA moves around a rebuild, with no block /// between any of them: a move pending at a rebuild whose own transaction moves `v` /// again, and a move, a rebuild, then a second live move. The first move is replaced diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index 1d7ab08cb..815555abf 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -1477,6 +1477,7 @@ where observation_armed_bytes: 0, observation_slots_high_water: 0, observation_forwarded: Vec::new(), + level_moves: None, }; let (offsets, pending, counts, armed, armed_bytes) = crate::control::observation_slots( &session.providers.effects, diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 39f9e0272..03b62bff5 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -639,6 +639,89 @@ pub struct SessionState { /// from the candidate it superseded (#1310, root ruling B15-Q4); empty after a rebuild that /// superseded nothing. pub(crate) observation_forwarded: Vec, + /// The level moves that may still ramp, as one conservative window (root ruling B17-R14, + /// #1648 split; TBD50 deletes it): `None` when none may. + pub(crate) level_moves: Option, +} + +/// Every live level move (a fader or offsets record with a ramp) and every level change a rebuild +/// commits, folded into one bound (B17-R14): the newest revision among them and the longest ramp +/// any of them takes. A move is in effect from at most the watermark's `first_sample` once the +/// watermark covers its revision (the watermark is monotone), so every move has ended once the +/// watermark covers `revision` and the render clock is at or past `first_sample + ramp`. The bound +/// only errs toward "may still ramp", and it is one word pair: nothing grows with the moves. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct LevelMoveWindow { + revision: u64, + ramp: u32, +} + +impl LevelMoveWindow { + /// Whether some move of the window may still ramp, given the watermark (`None` when a read + /// gave up: then it may) and the render clock read after it. + fn may_ramp(self, watermark: Option, render_clock: u64) -> bool { + watermark.is_none_or(|watermark| { + watermark.revision < self.revision + || render_clock < watermark.first_sample.saturating_add(u64::from(self.ramp)) + }) + } +} + +/// A transaction's level moves: the newest revision and the longest ramp (B17-R14). +fn fold_level_move(window: &mut Option, revision: u64, ramp: u32) { + *window = Some(match *window { + Some(window) => LevelMoveWindow { + revision: window.revision.max(revision), + ramp: window.ramp.max(ramp), + }, + None => LevelMoveWindow { revision, ramp }, + }); +} + +/// The longest ramp a level change a rebuild commits can take at its successor's first block +/// (B17-R14): the session's fader length (the jump length `J` and the default record ramp), or a +/// longer one the transaction gave a row. +fn rebuild_level_ramp(model: &session::SessionModel, edit_ramps: &session::EditRamps) -> u32 { + edit_ramps + .entries() + .into_iter() + .map(|(_, _, samples)| samples) + .fold( + controls::LiveRamps::for_session(model).fader_samples, + u32::max, + ) +} + +/// Whether some strip both models hold has a different effective fader or `S` on a lane in +/// `next` (B17-R14): a level change the rebuild's successor applies at its first block, and +/// which ramps from there. +fn rebuild_moves_a_level( + committed: &session::SessionModel, + next: &session::SessionModel, + monitor: &controls::MonitorOverlay, + next_monitor: &controls::MonitorOverlay, +) -> bool { + let (before, after) = ( + controls::effective(committed, monitor), + controls::effective(next, next_monitor), + ); + let track_count = committed.tracks.len(); + let ids: Vec<&str> = committed.strips().map(|strip| strip.id.as_str()).collect(); + next.strips().zip(&after.strips).any(|(strip, value)| { + host_core::strip_position( + ids.len(), + track_count, + |index| ids[index], + strip.id.as_str(), + ) + .and_then(|index| before.strips.get(index)) + .is_some_and(|old| { + (0..2).any(|lane| { + old.fader_db[lane].to_bits() != value.fader_db[lane].to_bits() + || old.offsets_sum[lane].to_bits() != value.offsets_sum[lane].to_bits() + }) + }) + }) } /// The stable key of one observation tap, borrowed from the bytes that name it (#1677): the strip's @@ -2513,6 +2596,44 @@ where }; #[cfg(not(feature = "test-support"))] let adoption = PlanAdoption::Next; + // B17-R14 (#1648 split; TBD50 deletes this): a successor that automates a fader lane may + // Reposition it (a changed table, or a moved input arrival), and a level move still + // ramping there is landed over `J` when less than `J` is left. Control cannot tell which + // lanes reposition or how much is left, so the candidate completes as the transition + // fallback whenever any level move may still ramp. The rebuild's own level changes join + // the window with the commit. A declared discontinuity carries nothing. + let (outcome, rebuild_level) = match &kind { + Rebuild::Transaction(prepared) => { + let session = prepared.get().prospective_session(); + let next = session + .compiled() + .ok_or(CommandError::Internal)? + .normalized_model(); + let committed = self.controller.session().compiled().normalized_model(); + let automates = next + .automation + .iter() + .any(|entry| controls::automation_row_renders(&entry.target)); + let in_flight = self.level_moves.is_some_and(|window| { + window.may_ramp( + self.shared.watermark.read().ok(), + self.publisher.render_clock(), + ) + }); + let outcome = if automates && in_flight { + engine::realtime::CandidateOutcome::TransitionFallback + } else { + engine::realtime::CandidateOutcome::Exact + }; + let moved = + rebuild_moves_a_level(committed, next, &self.monitor, &monitor_change.overlay); + ( + outcome, + moved.then(|| rebuild_level_ramp(next, session.edit_ramps())), + ) + } + Rebuild::Discontinuity(_) => (engine::realtime::CandidateOutcome::Exact, None), + }; let reservation = match self .publisher .reserve_replacement(candidate_plan.take(), adoption) @@ -2677,6 +2798,7 @@ where ResourceFault::Allocation, ))); }; + reservation.set_outcome(outcome); // #1310 D4: the protocol's own commit predicate, the last check. if let Rebuild::Transaction(prepared) = &kind && self @@ -2702,6 +2824,10 @@ where // withdrawn candidate, drops below as a commit's displaced model does. Rebuild::Discontinuity(model) => (None, Some(*model)), }; + // B17-R14: the rebuild's own level changes ramp from its successor's first block. + if let Some(ramp) = rebuild_level { + fold_level_move(&mut self.level_moves, revision, ramp); + } // #1559 D8: the overlay is written after the last fallible step, with the commit. let monitor_ramp = writes_monitor.then_some(monitor_change.ramp); let previous_monitor = core::mem::replace(&mut self.monitor, monitor_change.overlay); @@ -3328,6 +3454,27 @@ where } else { protocol::EditPath::Live }; + // B17-R14: the longest ramp of the level moves this commit writes (fader and offsets + // records with a ramp), folded into the window once committed. + let level_ramp = delta + .strips + .iter() + .flat_map(|strip| { + let fader = strip.fader_records().filter_map(|record| match record { + host_core::TrackFaderRecord::FaderDb { + smoothing_samples, .. + } => Some(smoothing_samples), + host_core::TrackFaderRecord::Mute { .. } => None, + }); + let offsets = strip + .offsets + .iter() + .flatten() + .map(|record| record.smoothing_samples); + fader.chain(offsets) + }) + .filter(|&samples| samples != 0) + .max(); drop(delta); let published = core::mem::take(&mut owners.begun); @@ -3343,6 +3490,9 @@ where ); // #1559 D8: the overlay is written after the last fallible step, with the commit. self.monitor = monitor_change.overlay; + if let Some(ramp) = level_ramp { + fold_level_move(&mut self.level_moves, revision, ramp); + } for index in published { owners.effects[index] .commit_owner() diff --git a/crates/control-plane/src/plan.rs b/crates/control-plane/src/plan.rs index 451171c73..292b89fae 100644 --- a/crates/control-plane/src/plan.rs +++ b/crates/control-plane/src/plan.rs @@ -196,6 +196,15 @@ impl<'a> ObservedReservation<'a> { .set_superseded(superseded); } + /// The outcome the candidate's own revisions complete with (#1314 D1): the transition + /// fallback for a rebuild that may stretch a level move in flight (B17-R14). + pub(crate) fn set_outcome(&mut self, outcome: engine::realtime::CandidateOutcome) { + self.inner + .as_mut() + .expect("reservation is live") + .set_outcome(outcome); + } + pub(crate) fn commit(mut self) { self.inner .take() diff --git a/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index 9e7741218..451291f85 100644 --- a/docs/C_ABI_V1_QUALIFICATION.md +++ b/docs/C_ABI_V1_QUALIFICATION.md @@ -149,7 +149,9 @@ before it pushes a record, so the watermark never reads a revision the gate cann `struct_size` or a nonzero reserved word is `RESULT_INVALID_ARGUMENT`; a copy that keeps landing inside render's publication (bounded at 64 attempts) is `RESULT_BACKPRESSURE` with `out` untouched, and the host retries. In this slice every revision completes as `exact`; the -`superseded` and `transition_fallback` producers are #1310, #1397 and #1358. The frozen exported +`superseded` and `transition_fallback` producers are #1310, #1397 and #1358. `transition_fallback` +also reports a Reposition swap while a live level move may still ramp (conservative: it can be set +when the move was not stretched; #TBD50 removes it; root ruling B17-R14). The frozen exported set is now 16 `miso_engine_v1_*` definitions; `abi_smoke.c` checks the bit against the queried mask and queries a compiled plan with a valid and a wrong-size struct, `header_smoke.cpp` pins the size and every field offset, and diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 7165a3c55..f2e82d662 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -2884,6 +2884,87 @@ fn a_fader_automation_edit_rebuilds_alike_on_both_hosts() { } } +/// B17-R14 (#1648 split, TBD50): a table-only rebuild of a riding fader while a hand move with +/// its own 4,000 samples may still ramp completes as the transition fallback on both hosts, and +/// the same rebuild with no move in flight completes exactly. Both hosts take the one decision in +/// the shared control plane: the browser's status words carry the C ABI's watermark, flags +/// included, and both hosts render the same bits. +/// +/// Test value: red if either host marks the rebuild differently (an adapter-local rule), or if +/// the shared decision is not reached through the browser's control path. +#[test] +fn a_reposition_swap_while_a_level_move_ramps_reports_the_fallback_alike_on_both_hosts() { + let ride = |end_db: f32| SessionEdit::UpsertAutomation { + automation: session::Automation { + id: id("t1-fader-ride"), + target: address( + Owner::Strip(id(T1.id)), + Section::Builtins, + 5, + ParameterChannel::Both, + ), + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: 1_000_000, + start_value: 0.0, + end_value: end_db, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + }, + }; + let hand = CAbi::edits( + Edit::Fader { + strip: T1, + lanes: Lanes::Both, + db: -6.0, + }, + Some(4_000), + ); + for rate in RATES { + for (label, moved, expected) in [ + ("no move", false, capi::OUTCOME_EXACT), + ( + "a hand move in flight", + true, + capi::OUTCOME_TRANSITION_FALLBACK, + ), + ] { + let model = model_at(rate); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + let mut block = 0; + let both = |edits: &[SessionEdit], browser: &mut Rig, c_abi: &mut CAbi| { + let (b, b_reply) = browser.transaction_reply(edits); + let (c, c_reply) = c_abi.transaction(edits); + assert_eq!(b, c, "{rate} Hz, {label}: one verdict"); + assert_eq!(b_reply, c_reply, "{rate} Hz, {label}: one reply"); + }; + both(&[ride(-12.0)], &mut browser, &mut c_abi); + same_blocks(&mut browser, &mut c_abi, block..block + 4, label); + block += 4; + if moved { + both(&hand, &mut browser, &mut c_abi); + same_blocks(&mut browser, &mut c_abi, block..block + 3, label); + block += 3; + } + both(&[ride(-6.0)], &mut browser, &mut c_abi); + same_blocks(&mut browser, &mut c_abi, block..block + 3, label); + let watermark = c_abi.watermark(); + assert_eq!( + status_watermark(&browser), + watermark, + "{rate} Hz, {label}: the browser status carries the C ABI's watermark" + ); + assert_eq!( + watermark.2, expected, + "{rate} Hz, {label}: the rebuild's outcome flags" + ); + } + } +} + /// #1631. A `matrix_mode` switch under a stored pan entry is refused alike by both hosts: the same /// refusal and the same diagnostic (`control.inactive` at the entry's path, the row rule's /// own, through the rebuild route), and nothing commits -- the same expected revision still diff --git a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts index 33125f944..0fd06f93d 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts @@ -779,7 +779,9 @@ export interface MisoStatus { /// @internal readonly appliedSample: bigint; /// The watermark's outcome flags, the C ABI's bits: `EXACT` 1, `TRANSITION_FALLBACK` 2, - /// `SUPERSEDED` 4. + /// `SUPERSEDED` 4. `TRANSITION_FALLBACK` also reports a Reposition swap while a live level move + /// may still ramp (conservative: it can be set when the move was not stretched; #TBD50 removes + /// it). /// @internal readonly appliedOutcome: bigint; /// The module's memory size now: at least the boot size (`MisoAudioWorkletHost.memoryBytes`), diff --git a/sdk/src/browser/shipped-host.d.ts b/sdk/src/browser/shipped-host.d.ts index 33125f944..0fd06f93d 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -779,7 +779,9 @@ export interface MisoStatus { /// @internal readonly appliedSample: bigint; /// The watermark's outcome flags, the C ABI's bits: `EXACT` 1, `TRANSITION_FALLBACK` 2, - /// `SUPERSEDED` 4. + /// `SUPERSEDED` 4. `TRANSITION_FALLBACK` also reports a Reposition swap while a live level move + /// may still ramp (conservative: it can be set when the move was not stretched; #TBD50 removes + /// it). /// @internal readonly appliedOutcome: bigint; /// The module's memory size now: at least the boot size (`MisoAudioWorkletHost.memoryBytes`), From 0c42f6b746d7b485c9af1982b1a189954a5386dc Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 00:26:26 +0000 Subject: [PATCH 33/35] Keep the fallback window open over J after a flagged rebuild (#1648, B17-R15) A flagged rebuild may land a stretched live move over J from its first block, so it folds (its revision, J) into the level-move window: a second Reposition inside that landing is flagged too. Gate: a_second_reposition_inside_a_stretched_landing_reports_a_fallback. TBD50 and the #1648 record gain this case, the wider over-report, the MINOR 4 setup and the stage-target question to settle first. Co-Authored-By: Claude Opus 5.5 --- ...assify-and-carry-fader-automation-edits.md | 20 ++++++-- ...ves-own-length-across-a-reposition-swap.md | 37 +++++++++------ crates/capi/src/runtime/live_tests.rs | 46 +++++++++++++++++++ crates/control-plane/src/control.rs | 16 ++++--- 4 files changed, 97 insertions(+), 22 deletions(-) diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index fcd23d560..81b9a8988 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -784,9 +784,23 @@ elsewhere. `scripts/check-cross-targets.sh` passes. table did not move, a rebuild that Repositions no lane, a move on a strip that has no automation. After a newer revision, the window also over-reports because the newest revision is paired with the longest ramp. A rebuild while a move is still pending (no block has heard it) is flagged too. -- Not flagged (not a live level move): a cell's own jump of `J` that is still running at a - Reposition swap is extended in the same way. This is read from the code, not measured (TBD50 - context). + `may_ramp` reads the watermark's newest `first_sample`, so any later revision restarts the + window: a non-level edit, or the flagged rebuild itself. A stream of live edits therefore keeps + the window open after one level move. +- A second Reposition inside a stretched landing (verifier MAJOR 1, tranche 5, root option a): a + move with a ramp shorter than `J` (500) is flagged at R1 and landed over `J` from R1. Before this + fix the window closed `ramp` samples after R1's adoption, so a second table-only rebuild R2 + between R1 + ramp and R1 + `J` landed it over `J` again and reported EXACT (verifier: 6.9e-3 + with R2 5 blocks after R1, 1.9e-3 at 7 blocks). Fix: a flagged transaction rebuild also folds + `(its revision, J)` into the window, with `J = LiveRamps::for_session(next).fader_samples`, after + the commit and beside the rebuild's own level fold. Gate: + `a_second_reposition_inside_a_stretched_landing_reports_a_fallback` (capi): a VCA move of 500 + samples heard 2 blocks; R1 reports the fallback (count 1); R2, sent 5 blocks after R1, reports + it too (count 2). Red without the new fold (R2 reads EXACT, count 1), checked once. +- A cell's own jump of `J` that is still running at a Reposition swap and is landed over `J` again + is specified D2 behaviour, not a fallback: it has no user length, and a fresh plan with the new + table also jumps over `J`. The one exception is a stretched live move's landing, which the + tranche-5 fold above now covers. - Gates: `a_reposition_swap_while_a_level_move_ramps_reports_a_fallback` (`crates/capi/src/runtime/live_tests.rs`): a live VCA move heard 24, 26 and 30 blocks, and a move inside a rebuild heard 12 blocks, report `OUTCOME_TRANSITION_FALLBACK` (count 1) on the diff --git a/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md b/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md index c2452898e..e23faa976 100644 --- a/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md +++ b/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md @@ -39,18 +39,28 @@ conservative fallback flag of B17-R14 with the exact behaviour and deletes that So the watermark reports `OUTCOME_TRANSITION_FALLBACK`. This is conservative: it is also set when no move is stretched (any transaction rebuild of a session with fader automation while any level move may still ramp; see the #1648 decision record "the conservative fallback flag"). -- Also stretched and not flagged: a cell's own jump of `J` that is still running at a Reposition - swap (`remaining < J`, landed over `J` again from the swap). This is read from the code, not - measured. -- Found while landing B17-R13 (measured with a temporary probe): for every heard move on the moving - ride the stage's `fader_gain` at the swap is not the composed curve at the ramp's end (0.398 at - 12 blocks heard and 0.335 at 24, against 0.3018), so the adoption rule's comparison always - differs and every heard move lands at the swap (over `remaining`, or `J`). The 4.6e-6 at 12 - blocks heard comes from that landing recomputing the stage's ramp step. Find why the stage's - target moves during the move before designing the comparison. -- Also from B17-R13 (MINOR 4, not gated): the comparison point `at = end` in `settle_adoption` has - no gate, because of the finding above (the comparison differs at either point). A gate needs a - heard move whose stage target equals the curve at its ramp's end. +- First thing to find out, before the design work (root's note): why the stage target moves + during a heard move (0.398 at 12 blocks, 0.335 at 24, against 0.3018). This may be a defect of + its own. Measured while landing B17-R13 with a temporary probe: for every heard move on the + moving ride, the stage's `fader_gain` at the swap is not the composed curve at the ramp's end. + So the adoption rule's comparison always differs, and every heard move lands at the swap (over + `remaining`, or `J`). The 4.6e-6 at 12 blocks heard comes from that landing recomputing the + stage's ramp step. +- A second Reposition inside a stretched landing (B17-R13 tranche 5): a flagged rebuild also folds + `(its revision, J)` into the window, so a second table-only rebuild inside the first one's + landing over `J` is flagged too (gate + `a_second_reposition_inside_a_stretched_landing_reports_a_fallback`). D2 deletes this with the + rest of the flag. +- Further over-report: `may_ramp` reads the watermark's newest `first_sample`, so any later + revision restarts the window (a non-level edit, or the flagged rebuild itself). A stream of live + edits keeps it open after one level move. +- A cell's own jump of `J` that is still running at a Reposition swap and is landed over `J` again + is specified D2 behaviour, not a defect: it has no user length, and a fresh plan with the new + table also jumps over `J`. The exception is a stretched live move's landing, which is covered by + the tranche-5 fold today and by D1 after this issue. +- MINOR 4 (from B17-R13, not gated): the comparison point `at = end` in `settle_adoption` has no + gate. It is observable with a heard move on the flat ride (`S` moves, the curve does not), then + a table whose step falls inside the remaining ramp. G2 can use that setup. ## Decision to make at filing @@ -78,7 +88,8 @@ conservative fallback flag of B17-R14 with the exact behaviour and deletes that the swap is bit-equal to the uninterrupted engine's (or, where the table's own values differ, to a fresh plan prepared with the new table and given the same move), allocation-free, adopted, and the rebuild's watermark advance reports `OUTCOME_EXACT`. -- G2. The comparison point: a heard move whose stage target equals the curve at its ramp's end +- G2. The comparison point (setup: a heard move on the flat ride, then a table whose step falls + inside the remaining ramp): a heard move whose stage target equals the curve at its ramp's end keeps its own length with less than `J` left (red if the curve is compared at the block's first sample). - G3. The B17-R13 step gate and the #1648 gates stay green; realtime policy count unchanged or diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index b3c861f32..a0888e016 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -9355,6 +9355,52 @@ mod set_control { } } + /// B17-R13 tranche 5 (verifier MAJOR 1). A live VCA move with a 500-sample ramp, heard 2 + /// blocks, is stretched to `J` (960) by a table-only rebuild R1, which reports the + /// transition fallback; a second table-only rebuild R2 sent 5 blocks after R1's adoption, + /// inside that landing over `J` but past the move's own 500 samples, stretches it again + /// and reports the fallback too. + /// + /// Test value: red if a flagged rebuild does not keep the level-move window open for `J` + /// from its adoption (R2 then reports `OUTCOME_EXACT`, checked once). + #[test] + fn a_second_reposition_inside_a_stretched_landing_reports_a_fallback() { + let fallback = crate::abi::OUTCOME_TRANSITION_FALLBACK; + let document = riding(); + let track = first_track(&document); + let offset = session::AutomationOffset::default(); + let mut a = Rig::new(&document); + for _ in 0..4 { + a.step(); + } + send( + &mut a, + &[vca_move(-6.0, Some(500))], + protocol::EditPath::Live, + "the move", + ); + for _ in 0..2 { + a.step(); + } + for (label, end_db, fallbacks) in [("R1", -6.0, 1), ("R2", -9.0, 2)] { + send( + &mut a, + &[split_ride(&track, (10_000, end_db), offset)], + protocol::EditPath::Rebuild, + label, + ); + for _ in 0..5 { + a.step(); + } + assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); + assert_eq!( + outcome(&a), + (fallback, fallbacks), + "{label}: the rebuild's outcome flags and the transition-fallback count" + ); + } + } + /// U3 fold 2, MINOR (#1648 D2, D15-2). Two live VCA moves around a rebuild, with no block /// between any of them: a move pending at a rebuild whose own transaction moves `v` /// again, and a move, a rebuild, then a second live move. The first move is replaced diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 03b62bff5..6fe062559 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -2626,11 +2626,14 @@ where engine::realtime::CandidateOutcome::Exact }; let moved = - rebuild_moves_a_level(committed, next, &self.monitor, &monitor_change.overlay); - ( - outcome, - moved.then(|| rebuild_level_ramp(next, session.edit_ramps())), - ) + rebuild_moves_a_level(committed, next, &self.monitor, &monitor_change.overlay) + .then(|| rebuild_level_ramp(next, session.edit_ramps())); + // A flagged rebuild may land a stretched move over `J` from its first block, so + // the window stays open for `J` from there: a second Reposition inside that + // landing stretches it again (verifier MAJOR 1, B17-R13 tranche 5). + let landing = (outcome == engine::realtime::CandidateOutcome::TransitionFallback) + .then(|| controls::LiveRamps::for_session(next).fader_samples); + (outcome, moved.max(landing)) } Rebuild::Discontinuity(_) => (engine::realtime::CandidateOutcome::Exact, None), }; @@ -2824,7 +2827,8 @@ where // withdrawn candidate, drops below as a commit's displaced model does. Rebuild::Discontinuity(model) => (None, Some(*model)), }; - // B17-R14: the rebuild's own level changes ramp from its successor's first block. + // B17-R14: the rebuild's own level changes ramp from its successor's first block, and a + // flagged rebuild's landing over `J` does too. if let Some(ramp) = rebuild_level { fold_level_move(&mut self.level_moves, revision, ramp); } From 45f4165ae39007f0f46a6cbb7d0afdd813ee5c82 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 01:00:17 +0000 Subject: [PATCH 34/35] Number the batch 17 placeholders: #1709, #1710, #1711 Co-Authored-By: Claude Opus 5.5 --- .../1648-classify-and-carry-fader-automation-edits.md | 6 +++--- ...compose-vca-offsets-with-stored-fader-automation.md | 6 +++--- ...offset-an-automated-fader-or-trim-by-a-hand-move.md | 4 ++-- ...evel-values-inside-the-canonical-fp-environment.md} | 6 +++--- ...dex-automation-entries-per-strip-once-per-build.md} | 4 ++-- ...level-moves-own-length-across-a-reposition-swap.md} | 2 +- crates/capi/Cargo.toml | 2 +- crates/capi/include/miso_engine_v1.h | 2 +- crates/capi/src/abi.rs | 2 +- crates/capi/src/runtime/live_tests.rs | 10 +++++----- crates/control-plane/Cargo.toml | 2 +- crates/control-plane/src/compile.rs | 2 +- crates/control-plane/src/control.rs | 8 ++++---- crates/host-core/Cargo.toml | 2 +- crates/host-core/src/control_provider.rs | 4 ++-- crates/host-core/src/prepare.rs | 2 +- crates/host-core/tests/stored_fader_automation.rs | 6 +++--- crates/lane/src/fpenv.rs | 2 +- crates/lane/tests/fp_env.rs | 6 +++--- docs/C_ABI_V1_QUALIFICATION.md | 2 +- hosts/host-web/Cargo.toml | 2 +- hosts/host-web/src/tests.rs | 4 ++-- hosts/host-web/tests/control_parity.rs | 2 +- .../web/miso-engine-v1-audio-worklet-host.d.ts | 2 +- sdk/src/browser/shipped-host.d.ts | 2 +- 25 files changed, 46 insertions(+), 46 deletions(-) rename .github/ISSUE_SPECS/{TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md => 1709-compose-live-level-values-inside-the-canonical-fp-environment.md} (98%) rename .github/ISSUE_SPECS/{TBD49-index-automation-entries-per-strip-once-per-build.md => 1710-index-automation-entries-per-strip-once-per-build.md} (90%) rename .github/ISSUE_SPECS/{TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md => 1711-keep-a-live-level-moves-own-length-across-a-reposition-swap.md} (98%) diff --git a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index 81b9a8988..00c07a2ca 100644 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -725,14 +725,14 @@ elsewhere. `scripts/check-cross-targets.sh` passes. ## Split (root ruling B17-R13, batch 17) - #1648 closes on its passing gates. The in-flight Reposition length case moves to - `TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md`, following the "split a + `1711-keep-a-live-level-moves-own-length-across-a-reposition-swap.md`, following the "split a blocking gate early" practice (a gate that holds a passed batch goes to a successor after one fix round). - Current difference (verifier, at 0fac00ee9): a live level move still ramping with `remaining < J` at a Reposition swap is stretched to `J` by the landing `max(J, remaining)`. Gate setup: heard 24, 26 and 30 blocks (928, 672 and 160 samples left; `J` = 960). The rebuilt rig differs from the uninterrupted engine by 4.8e-4, 3.1e-3 and 2.1e-3. The watermark marks it - as a fallback under root ruling B17-R14 (control-time, conservative; see the next point). TBD50 + as a fallback under root ruling B17-R14 (control-time, conservative; see the next point). #1711 replaces that flag with the exact behaviour and deletes this path. - The step guard (regression fix, `settle_adoption` in `crates/builtins-compiler/src/lib.rs`): when the cell's program has a discontinuity exactly at the block's first sample @@ -747,7 +747,7 @@ elsewhere. `scripts/check-cross-targets.sh` passes. 5.2e-4 heard 12), allocation-free, adopted. Red without the guard (8.6e-2, step at the swap). Red when the long landing runs over the whole block (verifier's mutation m5: 8.6e-2, step one sample after the swap; this is MINOR 3). Each was checked once. -- MINOR 4 (`at = end` has no gate) moves to TBD50. Probe finding: for every heard move on the +- MINOR 4 (`at = end` has no gate) moves to #1711. Probe finding: for every heard move on the moving ride, the stage's `fader_gain` at the swap is not the composed curve at the ramp's end (0.398 at 12 blocks heard, 0.335 at 24, against 0.3018). The comparison therefore differs at either point, and `at` cannot be seen by the current tables. diff --git a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md index 2637ad5e1..8af10db0d 100644 --- a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md @@ -359,7 +359,7 @@ ramp)`, never past the next discontinuity, as `CellEvents` aims a ramp); gain 4. **Paths outside the list (mechanical / test):** `crates/host-core/src/lib.rs` (re-export `FaderOffsetsRecord`); `crates/host-core/tests/stored_fader_automation.rs` (the R17-4 oracle parity test now also writes offsets cells, one under a held unmute ramp); `hosts/host-web/src/tests.rs` - (TBD48 `canonical_live`, as TBD48's record asks); `control.rs` test snapshot gains + (#1709 `canonical_live`, as #1709's record asks); `control.rs` test snapshot gains `strip_offsets_cells` and `FaderCells::test_only_offsets_write_counts` (test-support). 5. **Superseded test.** #1648a's `an_automated_fader_lane_takes_no_fader_record` is replaced by gate 5's test, which keeps its D3 assertions (no `FaderDb` on an automated lane, the other @@ -390,7 +390,7 @@ ramp)`, never past the next discontinuity, as `CellEvents` aims a ramp); gain block, each asserted `live`; 0 allocations and frees per render call after warm-up). - Oracle (R17-4): `the_scalar_oracle_renders_the_banks_fader_events` with offsets writes; red with the oracle's `read_offsets` removed (plane 0 at sample 1920), checked once. -- TBD48: both `canonical_live` modules gain "a live VCA move on an automated lane". +- #1709: both `canonical_live` modules gain "a live VCA move on an automated lane". - Commands: `cargo test --locked -p controls -p control-plane` (and `--features test-support`), `-p host-core --all-features`, `-p builtins-compiler --features test-support`, `-p capi`, `-p host-web --features host-web/test-support --lib --test control_parity`: pass. @@ -447,4 +447,4 @@ Fold of the U3 review, part U3a, on `codex/controls-batch-17k` over `86271e096`, - U3 MINOR 2: the #1649 row of both `canonical_live` modules (C ABI and browser) was vacuous (an exact `f64` `S`). It now moves the VCA to `20` dB over a lane whose stored hand offset is `-1e-30` dB, so `S` rounds differently under the hostile word and reaches another gain; red with - the `SessionState::command` guard removed. Details in the TBD48 spec's "U3 fold (batch 17)". + the `SessionState::command` guard removed. Details in the #1709 spec's "U3 fold (batch 17)". diff --git a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md index 205e2dbdd..601c81abb 100644 --- a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md +++ b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md @@ -560,7 +560,7 @@ Fader only (R17-6); R17-1 = A. arm), `crates/session/tests/in_place.rs` (slot literal), `crates/host-core/tests/stored_fader_automation.rs` (the retained-heap charge now subtracts the catalog's own delta: the automated left row is a descriptor of its own, 235 bytes there, charged by the catalog's resources), `crates/protocol/src/{controller,message_wire}/tests.rs` - (row literals; one new invalid case), the re-pin mirrors, `hosts/host-web/src/tests.rs` (TBD48), + (row literals; one new invalid case), the re-pin mirrors, `hosts/host-web/src/tests.rs` (#1709), and the docs that described an automated fader (`docs/SESSION_SCHEMA_V1.md` offset paragraph now says it is heard and records departure 2; `docs/CONTROL_PROTOCOL_SEMANTICS.md`, `docs/C_ABI_V1_QUALIFICATION.md`, the C header's live-edits comment; `check-capi-abi.sh` ok). @@ -601,7 +601,7 @@ live `ratio` row is automated; 46 frames still. Mirrors: `scripts/check-protocol `message_wire/tests.rs` ("automated row that is not automatable"). - Gate 6: `a_hand_move_on_an_automated_fader_renders_allocation_free` (`crates/control-plane/tests/vca_automation_realtime.rs`). -- TBD48: both `canonical_live` modules gain "a hand move on an automated lane". +- #1709: both `canonical_live` modules gain "a hand move on an automated lane". **Checks run.** `cargo test --locked -p session -p protocol -p controls -p control-plane -p conformance` and `-p host-core --all-features` (953 passed); `-p capi` (206); `-p host-web --features diff --git a/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md b/.github/ISSUE_SPECS/1709-compose-live-level-values-inside-the-canonical-fp-environment.md similarity index 98% rename from .github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md rename to .github/ISSUE_SPECS/1709-compose-live-level-values-inside-the-canonical-fp-environment.md index b544d1f89..9bae588bf 100644 --- a/.github/ISSUE_SPECS/TBD48-compose-live-level-values-inside-the-canonical-fp-environment.md +++ b/.github/ISSUE_SPECS/1709-compose-live-level-values-inside-the-canonical-fp-environment.md @@ -1,7 +1,7 @@ # Compose live level values inside the canonical floating-point environment -Placeholder from the batch 17 U1 fold (controls coordinator, 2026-10-16). Root files it and gives -it a number. +Filed by root from the batch 17 U1 fold (controls coordinator, 2026-10-16). Root ruling R17-7: +it closes in batch 17. ## Product outcome @@ -54,7 +54,7 @@ effect coefficients). - #1646 (U1 fold: the preparation guard and its test pattern), #1649, #1650b (their live writes join the gate). -## Decision record: TBD48 (batch 17) +## Decision record: #1709 (batch 17) Root ruling R17-7: inside batch 17, closes in this PR. diff --git a/.github/ISSUE_SPECS/TBD49-index-automation-entries-per-strip-once-per-build.md b/.github/ISSUE_SPECS/1710-index-automation-entries-per-strip-once-per-build.md similarity index 90% rename from .github/ISSUE_SPECS/TBD49-index-automation-entries-per-strip-once-per-build.md rename to .github/ISSUE_SPECS/1710-index-automation-entries-per-strip-once-per-build.md index 43645c38b..401aa1ade 100644 --- a/.github/ISSUE_SPECS/TBD49-index-automation-entries-per-strip-once-per-build.md +++ b/.github/ISSUE_SPECS/1710-index-automation-entries-per-strip-once-per-build.md @@ -1,7 +1,7 @@ # Index the automation entries per strip once per build -Placeholder from batch 17 (#1650b's successor note; controls coordinator, 2026-10-16). Root files -it for the weekly performance pass. It is control-thread cost only; render is not touched. +Filed by root from batch 17 (#1650b's successor note; controls coordinator, 2026-10-16), for the +weekly performance pass. It is control-thread cost only; render is not touched. ## Product outcome diff --git a/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md b/.github/ISSUE_SPECS/1711-keep-a-live-level-moves-own-length-across-a-reposition-swap.md similarity index 98% rename from .github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md rename to .github/ISSUE_SPECS/1711-keep-a-live-level-moves-own-length-across-a-reposition-swap.md index e23faa976..10623fc97 100644 --- a/.github/ISSUE_SPECS/TBD50-keep-a-live-level-moves-own-length-across-a-reposition-swap.md +++ b/.github/ISSUE_SPECS/1711-keep-a-live-level-moves-own-length-across-a-reposition-swap.md @@ -1,6 +1,6 @@ # Keep a live level move's own length across a Reposition swap -Placeholder from batch 17 (#1648's split, root ruling B17-R13; controls coordinator, 2026-10-16). +Filed by root from batch 17 (#1648's split, root ruling B17-R13; controls coordinator, 2026-10-16). It follows the "split a blocking gate early" practice: #1648 closes on its passing gates, and the in-flight length case moves here. diff --git a/crates/capi/Cargo.toml b/crates/capi/Cargo.toml index 94cca7398..e297e55bb 100644 --- a/crates/capi/Cargo.toml +++ b/crates/capi/Cargo.toml @@ -28,7 +28,7 @@ builtins = { workspace = true, features = ["test-support"] } # #1309 D8: the control plane's observers and fault hooks, for capi's tests only. A dev-dependency # feature never reaches the release `capi` build (`scripts/check-capi-abi.sh` builds `-p capi`). control-plane = { workspace = true, features = ["test-support"] } -# TBD48: `lane::fpenv::under_hostile_fp_env`, the hostile caller environment of the live gate. +# #1709: `lane::fpenv::under_hostile_fp_env`, the hostile caller environment of the live gate. lane = { workspace = true, features = ["test-support"] } # B16-R8: `RealtimePlanOwner::active_plan_mut`, so the #1642 gates read the active plan's # timeline. A dev-dependency feature never reaches the release `capi` build. diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index 0878b4e7d..afc57c8bd 100644 --- a/crates/capi/include/miso_engine_v1.h +++ b/crates/capi/include/miso_engine_v1.h @@ -181,7 +181,7 @@ extern "C" { * MISO_ENGINE_V1_OUTCOME_TRANSITION_FALLBACK and MISO_ENGINE_V1_OUTCOME_SUPERSEDED. * MISO_ENGINE_V1_OUTCOME_TRANSITION_FALLBACK reports a warm successor's fallback to its * transition, or a Reposition swap while a live level move may still ramp (conservative: it can be - * set when the move was not stretched; #TBD50 removes it). The host + * set when the move was not stretched; #1711 removes it). The host * polls the watermark to learn that an edit is audible, never the event lane. The caller zeroes * reserved0 and reserved[] and sets struct_size, or the call returns * MISO_ENGINE_V1_INVALID_ARGUMENT; a copy that keeps landing inside render's publication diff --git a/crates/capi/src/abi.rs b/crates/capi/src/abi.rs index cc6aee23f..eb43982b9 100644 --- a/crates/capi/src/abi.rs +++ b/crates/capi/src/abi.rs @@ -94,7 +94,7 @@ pub const FEATURE_MASK: u64 = FEATURE_IMMUTABLE_SESSION pub const OUTCOME_EXACT: u64 = 1; /// Watermark outcome flag: a warm successor could not adopt, and the covered revisions completed /// through the transition it fell back to, or a Reposition swap while a live level move may still -/// ramp (conservative: it can be set when the move was not stretched; #TBD50 removes it). +/// ramp (conservative: it can be set when the move was not stretched; #1711 removes it). pub const OUTCOME_TRANSITION_FALLBACK: u64 = 2; /// Watermark outcome flag: the covered revisions include ones a newer candidate folded in when it /// replaced the candidate that carried them. diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index a0888e016..96cba400d 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -8069,9 +8069,9 @@ mod set_control { assert_eq!(rig.summary().3, 0, "still live"); } - // TBD48: live level values are composed in the canonical floating-point environment. + // #1709: live level values are composed in the canonical floating-point environment. - /// TBD48 D2 on the C ABI: a live move made while the host's control thread runs FTZ, DAZ and + /// #1709 D2 on the C ABI: a live move made while the host's control thread runs FTZ, DAZ and /// round toward zero. #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] mod canonical_live { @@ -8285,7 +8285,7 @@ mod set_control { } } - /// TBD48 D2 (C ABI): a live fader move on a VCA member, a live VCA offset move over it, + /// #1709 D2 (C ABI): a live fader move on a VCA member, a live VCA offset move over it, /// (#1649) a VCA move over a member whose fader is automated, which writes its offsets /// cell, and (#1650b) a hand move on that automated fader, which writes it too, each made /// while the caller's thread runs FTZ, DAZ and round toward zero, render from the end of @@ -8331,7 +8331,7 @@ mod set_control { ); } - /// TBD48 D1 (C ABI): a session compiled and booted from its JSON while the caller's + /// #1709 D1 (C ABI): a session compiled and booted from its JSON while the caller's /// thread runs FTZ, DAZ and round toward zero is the session a canonical boot gives: the /// same committed snapshot and the same rendered bits. The session's JSON parse reads /// `0.1` as another `f32` under that word (asserted), so the gate is not vacuous. @@ -9282,7 +9282,7 @@ mod set_control { (watermark.outcome_flags, watermark.transition_fallback_count) } - /// B17-R14 (#1648 split, TBD50). A table-only rebuild of the riding fader while a live + /// B17-R14 (#1648 split, #1711). A table-only rebuild of the riding fader while a live /// level move may still ramp completes as the transition fallback: a VCA move with its /// own 4,000 samples heard 24, 26 and 30 blocks (928, 672 and 160 samples left, landed /// over `J` = 960), and a VCA move committed inside a rebuild, heard 12 blocks, then a diff --git a/crates/control-plane/Cargo.toml b/crates/control-plane/Cargo.toml index 07fa7f532..383ca3218 100644 --- a/crates/control-plane/Cargo.toml +++ b/crates/control-plane/Cargo.toml @@ -20,7 +20,7 @@ controls.workspace = true engine.workspace = true effect-contract.workspace = true host-core = { workspace = true, features = ["control-provider"] } -# TBD48 D1: every control entry composes level values in `lane::CanonicalFpEnv`. +# #1709 D1: every control entry composes level values in `lane::CanonicalFpEnv`. lane.workspace = true protocol.workspace = true session.workspace = true diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index 815555abf..0012e572b 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -1301,7 +1301,7 @@ pub fn compile_children( where A::Row: Send + 'static, { - // TBD48 D1: the boot preparation, the catalog and every effective value composed here run in + // #1709 D1: the boot preparation, the catalog and every effective value composed here run in // the canonical floating-point environment, whatever the host's control thread has set. The // guard restores the caller's word on every return. let _canonical = lane::CanonicalFpEnv::enter(); diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 6fe062559..4f75dd8fd 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -640,7 +640,7 @@ pub struct SessionState { /// superseded nothing. pub(crate) observation_forwarded: Vec, /// The level moves that may still ramp, as one conservative window (root ruling B17-R14, - /// #1648 split; TBD50 deletes it): `None` when none may. + /// #1648 split; #1711 deletes it): `None` when none may. pub(crate) level_moves: Option, } @@ -2050,7 +2050,7 @@ where /// scratch ([`Self::command_response`]); a structural command either rides the newest plan's /// live lanes or prepares, admits and publishes a replacement plan. /// - /// **Floating-point environment (TBD48 D1).** The whole command runs in + /// **Floating-point environment (#1709 D1).** The whole command runs in /// `lane::CanonicalFpEnv`: the live classification composes each strip's effective fader /// (`controls::effective`, the VCA sum), the catalog stages its derived rows, and a rebuild /// prepares, all as a fresh preparation of the committed value does, whatever the host's @@ -2267,7 +2267,7 @@ where /// [`Self::declare_discontinuity`] after its service step: the one core path of a declaration, /// which a plain session seek also runs (B16-R9). fn declare_discontinuity_serviced(&mut self) -> Result<(), CommandError> { - // TBD48 D1: the successor's carried level values are composed as `Self::command`'s are. + // #1709 D1: the successor's carried level values are composed as `Self::command`'s are. let _canonical = lane::CanonicalFpEnv::enter(); if !self .newest_providers() @@ -2596,7 +2596,7 @@ where }; #[cfg(not(feature = "test-support"))] let adoption = PlanAdoption::Next; - // B17-R14 (#1648 split; TBD50 deletes this): a successor that automates a fader lane may + // B17-R14 (#1648 split; #1711 deletes this): a successor that automates a fader lane may // Reposition it (a changed table, or a moved input arrival), and a level move still // ramping there is landed over `J` when less than `J` is left. Control cannot tell which // lanes reposition or how much is left, so the candidate completes as the transition diff --git a/crates/host-core/Cargo.toml b/crates/host-core/Cargo.toml index 9df6709e1..0db7cf889 100644 --- a/crates/host-core/Cargo.toml +++ b/crates/host-core/Cargo.toml @@ -39,7 +39,7 @@ session.workspace = true source.workspace = true [dev-dependencies] -# #1646 D2 and TBD48: `lane::fpenv::under_hostile_fp_env`, the hostile caller environment. +# #1646 D2 and #1709: `lane::fpenv::under_hostile_fp_env`, the hostile caller environment. lane = { workspace = true, features = ["test-support"] } # #1646 gate 2: the program builder's byte report is the charge a fader automation adds. automation.workspace = true diff --git a/crates/host-core/src/control_provider.rs b/crates/host-core/src/control_provider.rs index 0d36a8116..ad200c9a5 100644 --- a/crates/host-core/src/control_provider.rs +++ b/crates/host-core/src/control_provider.rs @@ -1193,7 +1193,7 @@ impl ControlCatalog { overlay: &MonitorOverlay, sample_rate: u32, ) -> Result { - // TBD48 D1: the derived rows are composed as preparation composes them, in the + // #1709 D1: the derived rows are composed as preparation composes them, in the // canonical floating-point environment, so a row shows the value render applies. let _canonical = lane::CanonicalFpEnv::enter(); // The list is walked one owner at a time through one reused buffer, twice: once to size @@ -1506,7 +1506,7 @@ impl ControlCatalog { sample_rate: u32, delta: &crate::LiveDelta<'_>, ) -> Result { - // TBD48 D1: the staged derived rows are composed in the canonical environment, as + // #1709 D1: the staged derived rows are composed in the canonical environment, as // `Self::build` composes them. let _canonical = lane::CanonicalFpEnv::enter(); #[cfg(any(test, feature = "test-support"))] diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 74cbad8df..d0488f723 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -2156,7 +2156,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( ), PrepareDiagnostics, > { - // TBD48 D1: the whole preparation runs in the canonical floating-point environment, so a + // #1709 D1: the whole preparation runs in the canonical floating-point environment, so a // route or send gain (`compile_graph`'s `db_to_gain_f32`), a native effect's coefficients and // every value composed below are what a canonical thread prepares, whatever the caller has // set. The inner guards (builtins body, lowerings, `controls::effective`, the catalog) nest: diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index 8924a4be6..9c28d85b8 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -2803,7 +2803,7 @@ mod canonical_preparation { /// Static faders of distinct dB on every lane, input trims and filters, no effect (an effect's /// own preparation is outside this gate), a flat automated lane on track 2 and a moving /// automated pair on track 5. One VCA reaches tracks 1, 2 and 5; track 1's left lane is - /// [`VCA_MEMBER_DB`]. Every route carries its own non-0 dB gain (TBD48: `compile_graph` + /// [`VCA_MEMBER_DB`]. Every route carries its own non-0 dB gain (#1709: `compile_graph` /// converts it), returned third. fn fixture() -> (SessionModel, Vec, Vec) { let mut model = bank_model(); @@ -2858,7 +2858,7 @@ mod canonical_preparation { /// `controls::effective`, in the builtins preparation body or in /// `into_graph_artifact_with_banks`), so a flat automated lane and a static fader would take /// the caller's rounding; or if a route gain is converted outside it (remove the guard at the - /// entry of host-core's `prepare_host_runtime_with_live_controls_policy_and_spectrum`, TBD48). + /// entry of host-core's `prepare_host_runtime_with_live_controls_policy_and_spectrum`, #1709). #[test] fn a_hostile_caller_environment_prepares_the_canonical_faders() { let (model, decibels, route_decibels) = fixture(); @@ -2887,7 +2887,7 @@ mod canonical_preparation { moved > 0, "the gate is vacuous: no fixture fader converts differently under the hostile word" ); - // TBD48: a route gain the hostile word converts differently, so the guard at the entry + // #1709: a route gain the hostile word converts differently, so the guard at the entry // of host-core's preparation (around `compile_graph`) is discriminated too. let routes_moved = under_hostile_fp_env(|| { route_decibels diff --git a/crates/lane/src/fpenv.rs b/crates/lane/src/fpenv.rs index 926a80894..efda37306 100644 --- a/crates/lane/src/fpenv.rs +++ b/crates/lane/src/fpenv.rs @@ -399,7 +399,7 @@ pub fn canonical_fp_control_word() -> FpControlWord { {} } -/// The hostile caller word of the environment gates (#1646 D2, TBD48 D2): `saved` with +/// The hostile caller word of the environment gates (#1646 D2, #1709 D2): `saved` with /// flush-to-zero, denormals-are-zero and round toward zero set (MXCSR `FTZ`, `DAZ`, `RC = 11`), or /// on AArch64 `FPCR.FZ` and `RMode = 11` (AArch64 has no separate DAZ bit). #[cfg(all(feature = "test-support", target_arch = "x86_64"))] diff --git a/crates/lane/tests/fp_env.rs b/crates/lane/tests/fp_env.rs index d39ab2a82..9dfb889f1 100644 --- a/crates/lane/tests/fp_env.rs +++ b/crates/lane/tests/fp_env.rs @@ -107,14 +107,14 @@ mod x86 { ); } - /// TBD48 (U3 fold, batch 17): the test helper `under_hostile_fp_env` installs the hostile + /// #1709 (U3 fold, batch 17): the test helper `under_hostile_fp_env` installs the hostile /// word inside its body and writes the caller's exact word back after it, also when the body /// panics. The caller's word is itself non-canonical (round up, a sticky flag), so a helper /// that wrote the canonical word back would be seen. /// /// Test value: red if the helper leaves the hostile word on the caller's thread (on return or /// while unwinding), restores another word than the caller's, or does not install the hostile - /// word, so every TBD48 gate built on it would run in the wrong environment. + /// word, so every #1709 gate built on it would run in the wrong environment. #[cfg(feature = "test-support")] #[test] fn under_hostile_fp_env_restores_the_callers_word() { @@ -293,7 +293,7 @@ mod aarch64 { (base & !(0b11 << 22)) | FPCR_FZ | FPCR_DN | FPCR_RMODE_UP } - /// TBD48 (U3 fold, batch 17): as the `x86_64` test of the same name. + /// #1709 (U3 fold, batch 17): as the `x86_64` test of the same name. /// /// Test value: red if the helper leaves the hostile word on the caller's thread (on return or /// while unwinding), restores another word than the caller's, or does not install it. diff --git a/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index 451291f85..cfdd51cb8 100644 --- a/docs/C_ABI_V1_QUALIFICATION.md +++ b/docs/C_ABI_V1_QUALIFICATION.md @@ -151,7 +151,7 @@ inside render's publication (bounded at 64 attempts) is `RESULT_BACKPRESSURE` wi untouched, and the host retries. In this slice every revision completes as `exact`; the `superseded` and `transition_fallback` producers are #1310, #1397 and #1358. `transition_fallback` also reports a Reposition swap while a live level move may still ramp (conservative: it can be set -when the move was not stretched; #TBD50 removes it; root ruling B17-R14). The frozen exported +when the move was not stretched; #1711 removes it; root ruling B17-R14). The frozen exported set is now 16 `miso_engine_v1_*` definitions; `abi_smoke.c` checks the bit against the queried mask and queries a compiled plan with a valid and a wrong-size struct, `header_smoke.cpp` pins the size and every field offset, and diff --git a/hosts/host-web/Cargo.toml b/hosts/host-web/Cargo.toml index bea0fddef..cd25aefa4 100644 --- a/hosts/host-web/Cargo.toml +++ b/hosts/host-web/Cargo.toml @@ -43,7 +43,7 @@ math.workspace = true # reach `-p host-web`'s release build. capi.workspace = true # Dev-only: `tests/retained_ceilings.rs` picks each budget by the native lane width (#1060), and -# the TBD48 live gate runs a hostile caller environment (`lane::fpenv::under_hostile_fp_env`). +# the #1709 live gate runs a hostile caller environment (`lane::fpenv::under_hostile_fp_env`). lane = { workspace = true, features = ["test-support"] } # The tests name `protocol` directly: its command frames drive the control plane the C ABI embeds # beside the browser host (`tests/control_parity.rs`, #1560, which absorbed #1399's diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 9eb22d9f2..31bd40ea2 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -10230,7 +10230,7 @@ fn a_flat_fader_automation_renders_the_static_fader_bits_in_the_browser() { } } -/// TBD48 D2 (browser): a live level move made while the control thread runs FTZ, DAZ and round +/// #1709 D2 (browser): a live level move made while the control thread runs FTZ, DAZ and round /// toward zero. The browser host's own tests run natively, so the caller's word can be set. #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] mod canonical_live { @@ -10384,7 +10384,7 @@ mod canonical_live { } } - /// TBD48 D2 (browser): a live fader move on a VCA member, a live VCA offset move over it, + /// #1709 D2 (browser): a live fader move on a VCA member, a live VCA offset move over it, /// (#1649) a VCA move over a member whose fader is automated, and (#1650b) a hand move on /// that automated fader, each made through the browser's control entry while the caller's /// thread runs FTZ, DAZ and round toward zero, render from the end of their ramp the bits of diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index f2e82d662..892c0cb7f 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -2884,7 +2884,7 @@ fn a_fader_automation_edit_rebuilds_alike_on_both_hosts() { } } -/// B17-R14 (#1648 split, TBD50): a table-only rebuild of a riding fader while a hand move with +/// B17-R14 (#1648 split, #1711): a table-only rebuild of a riding fader while a hand move with /// its own 4,000 samples may still ramp completes as the transition fallback on both hosts, and /// the same rebuild with no move in flight completes exactly. Both hosts take the one decision in /// the shared control plane: the browser's status words carry the C ABI's watermark, flags diff --git a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts index 0fd06f93d..273b3f9b7 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts @@ -780,7 +780,7 @@ export interface MisoStatus { readonly appliedSample: bigint; /// The watermark's outcome flags, the C ABI's bits: `EXACT` 1, `TRANSITION_FALLBACK` 2, /// `SUPERSEDED` 4. `TRANSITION_FALLBACK` also reports a Reposition swap while a live level move - /// may still ramp (conservative: it can be set when the move was not stretched; #TBD50 removes + /// may still ramp (conservative: it can be set when the move was not stretched; #1711 removes /// it). /// @internal readonly appliedOutcome: bigint; diff --git a/sdk/src/browser/shipped-host.d.ts b/sdk/src/browser/shipped-host.d.ts index 0fd06f93d..273b3f9b7 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -780,7 +780,7 @@ export interface MisoStatus { readonly appliedSample: bigint; /// The watermark's outcome flags, the C ABI's bits: `EXACT` 1, `TRANSITION_FALLBACK` 2, /// `SUPERSEDED` 4. `TRANSITION_FALLBACK` also reports a Reposition swap while a live level move - /// may still ramp (conservative: it can be set when the move was not stretched; #TBD50 removes + /// may still ramp (conservative: it can be set when the move was not stretched; #1711 removes /// it). /// @internal readonly appliedOutcome: bigint; From c484f2bdead9354d514a4ff85ce41f0f059a8360 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 01:21:43 +0000 Subject: [PATCH 35/35] Prove the fader events on all eight tracks at any bank width (#1647) rendering_fader_events_allocates_nothing counted distinct in-bank lane indices and expected eight; on NEON (four-lane banks) the eight tracks form two banks and the set held four (PR #1712, AArch64 job). Each track's step now ends at 90,000 + 37 t, and the test asserts one jump at each of those eight samples. Checked locally on x86 at the default width, Simd8, Simd4 (also between render calls) and Scalar; the old assertion reproduces the CI failure at Simd4. No product code change. Co-Authored-By: Claude Opus 5.5 --- .../tests/stored_fader_automation.rs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index 9c28d85b8..5dec27c79 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -1091,7 +1091,10 @@ fn the_fader_events_do_not_depend_on_the_quantum() { } /// #1647 gate 6: 1,000 blocks with events on eight lanes (eight tracks, each a fade with steps on -/// its left lane) allocate and free nothing after a warm-up. +/// its left lane) allocate and free nothing after a warm-up. Track `t`'s step ends at +/// `90,000 + 37 t`, so its jump's sample names the track: every one of the eight tracks gives its +/// jump, whatever the bank width (eight lanes in one bank on AVX2, two banks of four on NEON and +/// Wasm, where `event.lane` is the lane inside its bank). /// /// Test value: red if render allocates for events. #[test] @@ -1106,10 +1109,11 @@ fn rendering_fader_events_allocates_nothing() { -40.0 + track as f32, 0.0, )]; - segments.push(segment(AutomationShape::Step, 60_000, 90_000, 0.0, -12.0)); + let jump = 90_000 + 37 * track as u64; + segments.push(segment(AutomationShape::Step, 60_000, jump, 0.0, -12.0)); segments.push(segment( AutomationShape::Linear, - 90_000, + jump, 128_000, -12.0, -40.0, @@ -1121,13 +1125,20 @@ fn rendering_fader_events_allocates_nothing() { rig.until(4 * 128); let _ = builtins_compiler::test_only_take_fader_events(); rig.allocations = (0, 0, 0); - let mut lanes = std::collections::BTreeSet::new(); + let mut jumps = std::collections::BTreeSet::new(); for _ in 0..1_000 { rig.block(); let (events, _) = builtins_compiler::test_only_take_fader_events(); - lanes.extend(events.iter().map(|event| (event.lane, event.channel))); + jumps.extend( + events + .iter() + .filter(|event| event.kind == automation::EventKind::Jump) + .map(|event| event.sample), + ); } - assert_eq!(lanes.len(), 8, "events on eight lanes: {lanes:?}"); + let expected: std::collections::BTreeSet = + (0..8).map(|track| 90_000 + 37 * track).collect(); + assert_eq!(jumps, expected, "one jump on each of the eight tracks"); assert_eq!(rig.allocations, (0, 0, 0), "1,000 blocks of fader events"); }