From 5fc3b7bd10079d90bed30629e23b651c7ae426e3 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 06:49:37 +0000 Subject: [PATCH 01/23] Check the batch 18 specs against main and remove the closed batch 17 specs Amend #1651, #1652 and #1653 with a batch 18 spec check on ad03780ee: anchors, batch 17 code (fader cells, the event driver, the carry, the conservative fallback flag), decision 17 rules, gates at Simd8 and Simd4, and the authorized paths. Remove the specs of #1646-#1650 and #1709, closed in batch 17. Co-Authored-By: Claude Opus 5.5 --- .../1646-prepare-stored-fader-automation.md | 631 ------------ ...7-render-moving-stored-fader-automation.md | 950 ------------------ ...assify-and-carry-fader-automation-edits.md | 820 --------------- ...ca-offsets-with-stored-fader-automation.md | 450 --------- ...-automated-fader-or-trim-by-a-hand-move.md | 644 ------------ .../1651-render-stored-mute-automation.md | 127 +++ ...mated-mute-on-following-sends-in-render.md | 135 +++ ...sends-route-cell-from-the-shared-commit.md | 66 ++ ...ues-inside-the-canonical-fp-environment.md | 168 ---- 9 files changed, 328 insertions(+), 3663 deletions(-) delete mode 100644 .github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md delete mode 100644 .github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md delete mode 100644 .github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md delete mode 100644 .github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md delete mode 100644 .github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md delete mode 100644 .github/ISSUE_SPECS/1709-compose-live-level-values-inside-the-canonical-fp-environment.md diff --git a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md deleted file mode 100644 index 3fc67368e..000000000 --- a/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md +++ /dev/null @@ -1,631 +0,0 @@ -# Prepare stored fader automation and render it flat - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answers A1 (A1.1, A1.4, A1.5) and A4, 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 - -Host-core preparation, on the C ABI and in the browser, compiles each automated fader lane of the -session's `automation` table into a cell program on the strip's fader bank, charges its bytes to -the graph plan rows, and starts the lane at its prepared value: the curve at the fader's node time -at render sample 0. A flat curve at `v` therefore renders the bits of a static fader at `v`, on -both hosts and through any VCA composition. A session with no stored automation prepares and -renders exactly as before. Moving curves, seeks and latency in render are #1647, in the same -batch and push. - -This slice lands after *Admit browser live edits in the Worker through the committed model* -(#1382). From then on every browser live edit goes through the shared commit, so the browser and -the C ABI meet one rule for a live edit of an automated lane (#1648), and no host-only -admission path remains that could write a live record onto an automated lane. - -## Context - -- **The table renders nothing today.** `docs/SESSION_SCHEMA_V1.md:220-225`; the builtin target - vocabulary (`crates/session/src/validate.rs:812-853`). The fader row is builtin parameter 5, - `PerLane` (`:830-831`). -- **Where the fader is prepared.** Builtins lowering bakes each strip's VCA-effective fader, once - (`crates/builtins-compiler/src/lib.rs:3519-3521`), from `effective_strip_faders` - (`crates/session/src/vca.rs:96-120`): `vca_effective_db(own, offsets)` sums the member's value - and each reaching VCA's offset in `f64`, in ascending VCA-ID order, clamps and rounds once - (`:19-34`). The gain is `checked_fader_gain` (`crates/builtins/src/lib.rs:4292-4297`), which calls - `db_gain`, `10^(dB/20)` through `math::pow` in `f64`, rounded once (`:5356-5364`). -- **The fader bank.** `FaderBankProcessor` (`crates/builtins-compiler/src/lib.rs:715-723`); the - per-node scalar fader is `FaderMuteRampBuiltins` (`crates/builtins/src/lib.rs:4163-4233`). The - fader node is `TrackStage::PostFader` (`crates/graph/src/lib.rs:257-265`). -- **The fader is seam-side.** The mono-collapse seam sits immediately before the fader: a collapsed - track duplicates its one plane into the fader, whose two channels are free to differ, so no fader - word gates the collapse (`SEAM_SIDE_WITNESS`, `crates/builtins-compiler/src/lib.rs:391-398`; - `:756-771`). The collapse predicate is `track_mono_source` (`:3967-3970`), and an asymmetric - input delay declines it through the structural witness (`:4025-4032`). -- **Graph plan rows.** Builtin bank storage joins `builtin_bank_bytes`, - `graph_incremental_plan_bytes` and `graph_session_plus_plan_bytes` through - `GraphResourceEstimate::checked_add_builtin_banks` (`crates/graph/src/lib.rs:654-687`); host-core - admits the total against `maximum_graph_session_plus_plan_bytes` - (`crates/host-core/src/prepare.rs:1609-1627`), and the C ABI's replacement peak adds both plans' - `graph_session_plus_plan_bytes` (`crates/capi/src/runtime/compile.rs:434-457`). The graph - compiler's own estimate (`crates/graph-compiler/src/estimate.rs:28-160`) sees no builtin bank - and no automation. -- **The floating-point environment.** Native render entries pin it with `CanonicalFpEnv` - (`crates/lane/src/fpenv.rs:287-290`; the wasm form `:369-372` changes nothing), entered at - `crates/host-core/src/render_session.rs:111`; preparation runs in the host's environment (README - finding F11). -- **What this slice builds on.** #1626 and #1645 (one entry per lane, the hold rule, the fader's - unit and domain), #1637 (the builder, its byte report and the jump-length key), #1638 (`SetGain`), #1639 - (the plan cell that holds the jump lengths, seeded from #1054's - `control_smoothing_samples()`). #1285 D2 records each node's floored input arrival `a(n)`. #1312 - gives the latest-target cell (`crates/engine/src/realtime/latest_cell.rs`, #1312 D1); its cells hold two words (fader, - mute) or five (matrix) (#1312 D3), and this slice's offsets cell holds three (D1). -- **Root's decision F16** (README, "Findings for the coordinator"): the VCA offsets add first, - then the member (D6), under the standing summation-order ruling as root reads it. - -## Decisions frozen for this slice - -- **D1. Cells.** Host-core preparation compiles one cell per automated fader lane (target rack - `builtins`, parameter 5; `both` gives two cells) with #1637's builder: - - `a(n)` is the floored input arrival of the strip's `PostFader` node (#1285 D2); - - grid period 64, grid ramp 64, completion `End`; jump length `JumpLength::Block` (#1637 D2, - as shipped): the program stores no jump length; at render the caller resolves the fader - row's jump length for the block with `LiveRamps::for_row` from #1639's plan cell and passes - it to `CellEvents::new` as one `u32` (#1637's decision record); no `no_restart`; - - **the offsets cell** (layout 4 of README finding F16). Preparation builds - #1649's offsets cell for every automated fader lane, in every session, with or without - VCAs: a #1312 latest-target cell of three words, the lane's offset sum `S` as one `f64` (two - `u32` words) and one `u32` ramp word that #1649 writes (the length of the jump a VCA edit - asks for; seeded with the session's fader length, #1054). `S` is - `vca_offsets_sum(reach offsets)` (D6) for the lane's channel, and `+0.0` when no VCA reaches the - lane. A value `v` becomes the gain `checked_fader_gain(vca_compose_db(v, S))` (D6), with `S` - from the cell's newest slot. No static copy of `S` is kept in the program, so one source holds - it; #1649 adds only the live VCA edit that rewrites the cell. Because the cell exists on - every automated fader lane, a VCA that a later edit adds or attaches only rewrites it. - - **Why the cell keeps the bits.** With a reach, D6 makes the static path compute - `vca_compose_db(member, S)` with the same `S`, so the bits are equal by construction. With no - reach, the static path returns `v` unchanged, and render computes `clamp(f64(v) + (+0.0))`. In - `f64`, `v + (+0.0) == v` for every `v` except `-0.0`, which becomes `+0.0`; `v` lies in - `[-144, 24]` (#1645), so the clamp changes nothing. `db_gain(+0.0)` and `db_gain(-0.0)` are - both exactly 1 (`math::pow(10, ±0) = 1`), so the gain has the static bits in every case. - #1637's "change only" compares the curve value, before the offsets, and #1649 D3 compares the - composed gain, never the dB sum, so a sign of zero never causes a retarget. - - **Where the event state lives.** Each cell's event state (#1637's `CellState`) lives in the - fader bank stage beside the lane's ramp state, keyed by the cell's stable address (strip, row - 5, channel). So the stage's carry program (*Carry fader, mute and pan ramps across a plan - swap*, #1277) moves it with the ramp state at a swap, and no second carry path exists. #1648 - relies on that carry. - The program is installed on the strip's fader bank processor (or the scalar track's), as - immutable plan data. -- **D2. Prepared value.** After the graph compile and before publication, each automated lane is - set exactly (#1638 `SetGain`, off the render thread) to the curve at node time `-a(n)`, - composed as in D1. By the hold rule the curve at any `t <= first.start_sample` is the first - `start_value`, and every `start_sample >= 0`, so `curve(-a(n)) == curve(0)` always: the prepared - value is the entry's first `start_value`, whatever `a(n)` is (README A1.4, "New lanes"). A lane - whose curve is flat at `v` is therefore set to the bits a static `v` prepares. Every curve - evaluation and conversion of preparation runs inside `CanonicalFpEnv`, as render's do (README - A1.1). A conversion that fails is unreachable (#1645 - bounds the curve to the fader domain and `vca_compose_db` clamps, D6) and refuses preparation with - `host.automation.fader`. -- **D3. Mono collapse is kept.** The fader is seam-side (Context), so a fader automation that drives - one lane, or both lanes with different curves, keeps the track's collapse. This refines the - README's mono-collapse rule (A1.5), which holds for rows upstream of the seam: trim, polarity, - HPF, LPF and strip effects (#1658, #1659-#1660, #1663-#1664). Nothing is added to - `track_mono_source`. -- **D4. Bytes.** Each fader program's bytes (builder report, #1637 D5) and each offsets cell's - bytes (three slots of three 4-byte words, 36 bytes, plus the cell's fixed words) are charged as builtin - bank payload through `checked_add_builtin_banks`, so they reach `builtin_bank_bytes`, - `graph_incremental_plan_bytes` and `graph_session_plus_plan_bytes`, the caller's - `maximum_graph_session_plus_plan_bytes` and the C ABI's replacement peak. This row is more exact - than `crates/graph-compiler/src/estimate.rs`, which never sees the program. No ABI row is added. -- **D5. The acked-batch question.** Stored automation has no queue: the program is plan data. - Nothing is acknowledged and later dropped. -- **D6. The composition order (README F16, root's decision).** `crates/session/src/vca.rs` gains two functions - and `vca_effective_db` is written from them, so every caller (`effective_strip_faders`, #1247's - classifier row, host-core's live VCA state, builtins lowering) takes the new order: - - `vca_offsets_sum(offsets_db) -> Option`: `None` for no offset; otherwise the `f64` sum in - the order given, starting from the first offset; - - `vca_compose_db(member_db: f32, offsets_sum: f64) -> f32`: - `(f64(member_db) + offsets_sum).clamp(-144, 24) as f32`; - - `vca_effective_db(member, offsets)`: `member` bit for bit when `vca_offsets_sum` is `None`, - otherwise `vca_compose_db(member, sum)`. - - `effective_strip_faders` still passes the reach in ascending VCA-ID order. Three test oracles - compute the member-first order today and are rewritten in this change to sum the offsets first, - then add the member (`vca_offsets_sum` and `vca_compose_db`, or the same arithmetic written out): - - `reference_db` (`crates/host-core/tests/vca.rs:268-279`), with its rustdoc; - - the `lane` closure of `a_browser_vca_renders_as_its_effective_faders_under_solo_and_mute` - (`hosts/host-web/src/tests.rs:13358-13371`); - - `vca_reference_effective` (`hosts/host-web/src/tests.rs:13697-13715`), with its rustdoc. Its - clamp flag compares the clamped value with the new sum. - - The sentence at - `docs/SESSION_SCHEMA_V1.md:80` becomes "summed in `f64` (the reaching VCAs' offsets first, in - ascending VCA ID, then its own value added to that sum)". This is a class B change to #1242's - order that root ruled under the standing summation-order ruling (README F16); it moves the bits of a static session only when - a nonzero member or offset is smaller than `k·2^-20` dB, `k` the term count (README F16). - -## Deliverables - -1. D1, D2 and D4 in `crates/host-core/src/prepare.rs` and `crates/builtins-compiler/src/lib.rs` (the - program and the event state on the fader bank processor and the scalar track, the offsets cells - and their seed, the prepared value, the bank-payload charge). -2. The three-word cell of D1 in `crates/engine/src/realtime/latest_cell.rs`, with its loom case, - only if #1312's module has no three-word form (its two- and five-word cells, #1312 D3, show the - form it uses). -3. D6 in `crates/session/src/vca.rs` and `crates/session/src/lib.rs`, its gate rewritten in - `crates/session/tests/vca_composition.rs`, the three oracles of D6 rewritten in - `crates/host-core/tests/vca.rs` and `hosts/host-web/src/tests.rs`, and the sentence in - `docs/SESSION_SCHEMA_V1.md:80`. -4. Tests in `crates/host-core/tests/stored_fader_automation.rs` (new), - `crates/capi/src/runtime/tests.rs` and `hosts/host-web/src/tests.rs` (one test). - -## Authorized paths - -- `crates/host-core/src/prepare.rs`, `crates/host-core/tests/stored_fader_automation.rs` (new) -- `crates/builtins-compiler/src/lib.rs` (program and event-state installation on the fader bank - processor and the scalar track; the offsets cell's read; the program charge) -- `crates/capi/src/runtime/tests.rs`, `hosts/host-web/src/tests.rs` (one new test, and the two - oracles of D6 at `:13358-13371` and `:13697-13715`) -- `crates/host-core/tests/vca.rs` (`reference_db`, `:268-279`, only) -- `crates/automation/src/events.rs` (one method that seeds a `CellState`'s target, and its test, - only if D2's event state needs a target at preparation: `CellState::new` leaves it empty and no - setter exists; #1648's batch 13b amendment authorizes the same method, and whichever lands first - adds it) -- `crates/engine/src/realtime/latest_cell.rs` (a three-word cell form and its loom case, only if - #1312's module has none) -- `crates/session/src/vca.rs` (D6), `crates/session/src/lib.rs` (the `pub use` at `:31` only), - `crates/session/tests/vca_composition.rs` (gate 1's rewrite, the order cases and the reference), - `docs/SESSION_SCHEMA_V1.md` (the composition sentence at `:80` only) -- Tests that pin a builtin bank or graph plan row that D4 moves for a session with automation (none - exists today; a session without automation moves no row) -- `crates/host-core/Cargo.toml` (a normal `automation` dependency: preparation runs #1637's - builder), `Cargo.lock` - -## Non-goals - -- Events in render: the grid, jumps, held events, seeks, the timed chain call (#1647). -- The documents that say the fader row renders (#1647). -- Classifying fader automation edits, the rule for a live edit of an automated lane, and carrying - event state across a swap (#1648). -- The live VCA move that rewrites the offsets cell (#1649). The plan cell of jump lengths and - its edit path (#1639). -- Mute, pan, matrix, input and effect rows (#1651 to #1667). - -## Hazards - -- **Batch R1, one push with #1647, #1648 and #1649** (README "Must-land-together groups"). Alone, - this slice starts a moving curve at its prepared value and holds it there; #1647 renders the - motion, #1648 stops a live fader edit from fighting the curve, and #1649 makes a VCA ride - heard on an automated member. The four merge in one push, so `main` never holds a partial state. -- **Host-core is stream A's file** (`crates/host-core/src/prepare.rs`); root orders the merge after - #1285 and #1312. -- **Bit-identity with VCAs.** D6's one rule serves the static path and the cell, so `S` in the - cell is the static path's own sum; #1649's live edit computes it with the same function, so - it keeps the same property. -- **A pinned order changes** (D6). The amendment to #1242's documented order, its pinned test and - the three oracles of D6 land in this slice, together, so `main` never holds a test that pins or - computes the old order. - -## Objective gates - -1. **Flat equals static** (`crates/capi/src/runtime/tests.rs`, new; `hosts/host-web/src/tests.rs`, - new). A session with a flat fader automation at `v = -7.25` dB (one `linear` segment from `v` - to `v`) on one track renders, bit for bit, the session with static `fader_db` `-7.25` and no - automation, for 64 blocks, on the C ABI and on the browser host. The same with a `both` entry on - a track one VCA reaches with an offset of `+3` dB, and on a track three VCAs reach, with a flat - value and offsets for which the two orders of README F16 give different gain bits. Different - `f64` sums are not enough: they can round to the same `f32` dB value. An example: the flat value - `f32::from_bits(0x405a_f905)` (about 3.42145 dB) and the offsets, in ascending VCA-ID order, - `f32::from_bits(0xc1af_fc6c)` (about -21.99825 dB), `24.0` and `f32::from_bits(0x2624_8cd2)` - (about 5.7e-16 dB). The member-first order gives the dB bits `0x40ad_8ad3`, the offsets-first - order `0x40ad_8ad2`. The test first asserts that `checked_fader_gain` of the two orders' dB - values differs in bits, so a render that keeps the other order than the static path cannot - pass. The same in a session with two VCAs, for a track that neither reaches and whose flat value - is `-0.0` dB (the cell's `S = +0.0`). -2. **Bytes.** 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 the builder's - byte report for that program, and a `maximum_graph_session_plus_plan_bytes` one byte below the - session's row refuses it with `host.graph.resource.limit`. -3. **Mono collapse kept.** A mono track (source channels `0, 0`) with a flat `left`-only fader - automation at a value its right lane does not have keeps its collapse, and its output equals the - dual render of a twin whose two source channels carry the same PCM (`0, 1`). -4. **No change without automation.** Every existing render test passes with unchanged digests; - `./target/release/audit capi` shows the same `pcm_digest` as the base (PR evidence; the audit - session has no automation) and 0 violations. -5. **The three-word cell**, only if Deliverable 2 adds a form (`crates/engine/src/realtime/latest_cell.rs`, - new loom case beside #1312's `spsc_loom_cells_*`). A three-word cell never shows a slot that - mixes two writes and never skips the newest completed write under a racing writer. -6. **The order** (`crates/session/tests/vca_composition.rs`, gate 1 of #1242 rewritten in place). - The reference sums the offsets first, then adds the member. The crafted cases become - `vca_effective_db(24.0, [-24.0, 1e-30]) == 0.0` (today `1e-30`, line 81), - `vca_effective_db(1e-30, [24.0, -24.0]) == 1e-30` (today `0.0`) and - `vca_effective_db(0.0, [24.0, -24.0, 1e-30]) == 1e-30` (offsets in the order given); the - no-offset and clamp cases stay. Its rustdoc (`:46-51`) states the new order. -7. **Commands:** - - `cargo test --locked -p session` - - `cargo test --locked -p host-core --features host-core/test-support` - - `cargo test --locked -p builtins-compiler --features test-support` - - `cargo test --locked -p capi` - - `cargo test --locked -p host-web --features host-web/test-support` - - `CARGO_TARGET_DIR=target/ci/loom RUSTFLAGS='--cfg loom --check-cfg=cfg(loom)' cargo test - --locked --release -p engine --lib spsc_loom` - - `cargo build --locked --release -p audit -p bench -p capi -p session-validator`, then - `./target/release/audit capi` - - `bash scripts/run-aarch64-tests.sh debug` (the `aarch64-debug` job) - - `bash scripts/check-host-core-policy.sh`, `bash scripts/check-workspace-policy.sh` - - `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` - -## Test value - -- Gate 1 turns red if render composes the offsets in another order than the static path, if the - `+0.0` of a lane no VCA reaches changes the gain bits, or if either host converts dB differently. -- Gate 2 turns red if the program's or the offsets cell's bytes escape the caller's graph cap or the - replacement peak. -- Gate 3 turns red if fader automation declines the collapse, which costs a plane per track for no - audible change. -- Gate 5 turns red if a new three-word cell form can tear or skip. -- Gate 6 turns red if the static composition keeps the member-first order or sums the offsets in - another order than given; the rewritten line 81 replaces the case that pinned today's order. The - three rewritten oracles of D6 are not new tests: they keep their gates and now state the order - that the engine computes. - -## Dependencies - -- #1626 *Validate stored automation lanes in the session crate and state the hold rule*. -- #1645 *Validate builtin automation targets against their rows at preparation*. -- #1637 *Compile stored automation into per-cell events in node time*. -- #1638 *Apply timed operations inside a block on the strip fader stage*. -- #1639 *Hold the automation jump lengths in a plan cell*: the lengths from which a caller resolves - the fader row's jump length with `LiveRamps::for_row` (#1637 as shipped, departure 1 of its - decision record; no program holds a jump-length key). -- *Session controlSmoothing: configurable ramp lengths for live mute, fader and pan changes* - (#1054): the fader jump length. -- *Keep every node's latency from dropping during playback* (#1285): `a(n)`. -- *Extract the C ABI control plane into a portable crate both hosts call* (#1309): the C ABI - preparation path both hosts share. -- *Hold live values in latest-target cells on both hosts* (#1312): the cell module this slice adds - a constructor to, and the merge order of the fader bank processor. -- *Admit browser live edits in the Worker through the committed model* (#1382): every browser live - edit goes through the shared commit before any host renders stored automation. #1382 brings the - shared classifier rows of #1225, #1226, #1247, #1261, #1262 and #1390 and the cells of #1345, - #1346 and #1347, which the later drafts build on. -- Batch: R1, in one push with #1647 *Render moving stored fader automation, seeks and - latency*, #1648 *Classify fader automation edits as carried rebuilds* and #1649 *Compose VCA offsets - with stored fader automation*. - - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **The composition moved.** `effective_strip_faders` is gone; `controls::effective` (decision 17 - C7, `crates/controls/src/effective.rs`) composes every effective fader, through - `session::vca_effective_db` and `session::VcaSum`, which still add the member first - (`crates/session/src/vca.rs:20-60`). D6 changes `VcaSum` and `vca_effective_db` and adds - `vca_offsets_sum` and `vca_compose_db` there; every caller follows. -- **The pinned order test moved** to `crates/controls/tests/composition.rs:82-83` (and its - `reference_db`, `:37`); the browser oracles of D6 were deleted with the browser's own path - (#1565). The oracles to rewrite are `crates/controls/tests/composition.rs` and - `crates/host-core/tests/vca.rs:271`. Authorized paths change to these files. -- **The hand offset joins `S`** (OQ1 = C, #1650 D6): `S = offset + Σ reaching VCA offsets`. - This slice seeds `S` with the stored offset; #1650 adds the field and the write. -- **Live lanes on every plan** (C6): every strip already has its `FaderCells` - (`crates/builtins-compiler/src/lib.rs:298`); the offsets cell is an extra cell for automated lanes - only. -- **Moved files.** `crates/capi/src/runtime/compile.rs` is now `crates/control-plane/src/compile.rs`. - #1312, #1054 and #1309 have landed; #1285 is open. -- **The browser.** #1382 is closed; its role is #1562. #1562a (the browser commits through - `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. - - -## 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. - -## 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). - -*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 - 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. - *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 - 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. - -## 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/1647-render-moving-stored-fader-automation.md b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md deleted file mode 100644 index bd87afcdc..000000000 --- a/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md +++ /dev/null @@ -1,950 +0,0 @@ -# Render moving stored fader automation, seeks and latency - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answers A1 (A1.3-A1.5, A1.7) and A5, 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 producer's fader ride is heard on every platform. On the C ABI and in the browser, each automated -fader lane prepared by #1646 follows its curve in node time: it reaches the curve's value at -each ramp's completion sample on a fixed 64-sample grid, takes each discontinuity at its exact -sample over the session's fader ramp length, lines up with the audio it acts on through every -latent insert, and is set exactly where a session seek reaches the fader. The output does not -depend on the quantum or on where a seek landed. A session with no stored automation renders -exactly as before. The fader row leaves the classifier's mask in #1648, in the same batch. - -## Context - -- **Where the fader renders.** `FaderBankProcessor` (`crates/builtins-compiler/src/lib.rs:715-723`) - drains its lanes' live records, then processes the bank (`:738-752`); the fused fader-and-matrix - processor does the same and fuses only when nothing ramps (`:1198-1219`). Graph runs a strip's - builtin chain with the block's first sample (`crates/graph/src/runtime.rs:3046-3048`) through - `GraphPreparedBuiltinBankProcessor::process` (`crates/graph/src/lib.rs:1347-1353`). The graph - executor calls the source set at block entry (`crates/graph/src/lib.rs:3254-3256`), where #1641 - advances the timeline. -- **What #1646 leaves.** Each automated lane has a cell program (#1637) with its `a(n)`, its - grid and no jump length (#1637 as shipped, departure 1 of its decision record: a caller resolves - the fader row's length with `LiveRamps::for_row` from the three lengths #1639's plan cell holds), - an offsets cell (the lane's VCA offset sum `S`, `+0.0` when no VCA reaches it; #1646 D1), and - its event state in the fader bank stage. It starts at its prepared value, the curve at node time - `-a(n)`, which is the entry's first `start_value`. -- **What #1639 leaves.** The graph reads the plan's jump-length cell once at block entry and holds - its three words for the block; no stage reads them yet. -- **The documents.** `docs/SESSION_SCHEMA_V1.md:220-225` says the table renders nothing; the - comments at `crates/session/src/validate.rs:812-822` and `:842-853` say the same; - `docs/REALTIME_MEMORY.md:13` gives #1058 the rendering. -- **What this slice builds on.** #1640 and #1641 (the timeline and its reader), #1642 and #1643 (the session - seek on each host), #1637 (events), #1638 (operations inside a block, the held-gain rule). - -## Decisions frozen for this slice - -- **D1. Render.** Per block, after the live drain at block entry, the fader bank processor: - 1. reads, for each automated lane, its node-time mapping: graph passes the block's source-read - sample and a timeline reader (#1640) to the strip chain through a new defaulted method on - `GraphPreparedBuiltinBankProcessor` (`process_timed`, which forwards to `process` by default), - and the processor reads `timeline_block(s0 - a(n))` into #1637's `NodeSpan`. The same call - passes the block's jump lengths, the three words #1639's read gives the graph for that - block; for each cell the processor resolves the fader row's jump length from them with - `LiveRamps::for_row` and passes it to `CellEvents::new` as one `u32` (#1637 D4, as shipped: - #1637 stores no jump key and writes no row rule); - 2. tells each cell the end of a ramp in flight it did not start (a live mute ramp) through - `hold_until`, from the stage's `remaining` (#1638 D2); - 3. pulls each lane's events (#1637, `SeekMode::Exact`) and merges them by offset across lanes - with a fixed per-lane cursor (no buffer); - 4. converts each event value: `Grid` and `Jump` become `RetargetGain`, a held event - `RememberGain`, `Set` `SetGain` (#1638 D1), each gain composed as #1646 D1; - 5. runs `process_with` (#1638 D3). The fused fader-and-matrix path is taken only when no - fader or matrix lane moves in the block. - 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, - "New lanes"). Carrying a lane's event state across a swap is #1648's. -- **D3. Seeks.** A session seek reaches the fader where its node time steps; the cell emits `Set` - there and the lane takes the curve's value exactly, with no ramp (README A1.4). -- **D4. Docs.** `docs/SESSION_SCHEMA_V1.md:220-225`: the fader row (5) renders; every other row is - valid and inert until its slice. The comments at `crates/session/src/validate.rs:812-822` and - `:842-853` say the same. `docs/REALTIME_MEMORY.md:13`: stored fader automation is a compiled table - in the plan (#1058), with no queue. -- **D5. The acked-batch question.** Stored automation has no queue: render generates its events - from the plan. Nothing is acknowledged and later dropped. - -## Deliverables - -1. D1-D3 in `crates/builtins-compiler/src/lib.rs`, and the timed call in `crates/graph/src/lib.rs` - and `crates/graph/src/runtime.rs` (the strip chain call only). -2. D4's documents. -3. Tests in `crates/host-core/tests/stored_fader_automation.rs`. - -## Authorized paths - -- `crates/builtins-compiler/src/lib.rs` (the fader bank processor, the fused processor and the - scalar track's render path) -- `crates/graph/src/lib.rs` (`process_timed` on `GraphPreparedBuiltinBankProcessor` and the - timeline reader the chain passes), `crates/graph/src/runtime.rs` (the strip chain call) -- `crates/host-core/tests/stored_fader_automation.rs`, `crates/capi/src/runtime/tests.rs` - (gate 4 on the C ABI) -- `docs/SESSION_SCHEMA_V1.md`, `docs/REALTIME_MEMORY.md`, `crates/session/src/validate.rs` - (comments only) -- `crates/source/src/lib.rs` (the source driver's `timeline_block` reader only: #1641 D8 adds - only `timeline_at` and leaves the render-side block reader to this slice) - -## Non-goals - -- Preparation, prepared values, bytes and the mono collapse (#1646). -- Classifying fader automation edits, the rule for a live edit of an automated lane, and carrying - event state across a swap (#1648). -- The live VCA move that rewrites the offsets cell (#1649). The `control_smoothing` edit path - that writes the jump-length cell (#1639). -- Mute, pan, matrix, input and effect rows (#1651 to #1667). The live path's held-gain rule (#1054 - D10). -- A benchmark (#1671 and #1672). - -## Hazards - -- **One push with #1646, #1648 and #1649** (README "Must-land-together groups"). Until #1648 is - in, a rebuild for another reason sets each automated lane exactly at adoption (D2), and a live - fader edit on an automated lane, on either host, retargets it until the next event. Until - #1649 is in, an acknowledged VCA ride on an automated member is not heard. The four merge in one - push, so `main` never holds these states. -- **The chain call is shared.** `process_timed` defaults to `process`, so every other builtin bank - is untouched; only the fader bank processor and the scalar track override it. -- **No silence skip.** A lane with an event in the block takes the ramping path; a flat curve emits - no event and changes nothing (README A1.7, finding F10). - -## Objective gates - -1. **Completion samples** (`crates/host-core/tests/stored_fader_automation.rs`, new cases). 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 between them it is the ramp kernel's - interpolation. -2. **Jumps at exact samples.** 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 sample 1,000 and ends at `1,000 + fader length - 1`. **The next jump, - not the one in flight:** steps at timeline 1,000 and 3,000, fader length 480; a test-support - write of 960 to #1639's cell at a block entry while the first jump ramps: that jump completes - over 480 and the second ramps over 960, bit-identical to the stage driven directly with - #1638's operations at those offsets and lengths. -3. **Latency.** Two tracks on one source, the first with a latent insert before the fader (a - true-peak limiter, latency `λ`), each with a fader step at timeline 4,800: the step's event is at - render sample `4,800 + a(n)` on each track's fader (a test-support event probe), so the two - events are `λ` apart, and in the output both steps meet the source frame 4,800 they act on. -4. **Session seek.** After a session seek to `T` that reaches the fader at render sample `r`, the - lane equals `checked_fader_gain(curve(T))` exactly from `r`, with no ramp: through #1641's - producer in host-core, and through `miso_engine_v1_session_seek` in - `crates/capi/src/runtime/tests.rs`. -5. **Quantum invariance.** The same session at quantum 128 and at quantum 100 gives the same fader - output bits, sample for sample, from sample 0, with and without a session seek. -6. **No allocation, no change without automation.** 1,000 blocks with events on eight lanes allocate - and free nothing (`bench_support::alloc` thread counters after warming). Every existing render - test passes with unchanged digests; `./target/release/audit capi` shows the same `pcm_digest` as - the base (PR evidence; the audit session has no automation) and 0 violations. -7. **Commands:** - - `cargo test --locked -p host-core --features host-core/test-support` - - `cargo test --locked -p builtins-compiler --features test-support` - - `cargo test --locked -p graph --features graph/test-support` - - `cargo test --locked -p capi` - - `cargo test --locked -p host-web --features host-web/test-support` - - `cargo build --locked --release -p audit -p bench -p capi -p session-validator`, then - `./target/release/audit capi` - - `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`, - then `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/run-aarch64-tests.sh debug` (the `aarch64-debug` job) - - `bash scripts/check-realtime-policy.sh`, `bash scripts/check-host-core-policy.sh`, - `bash scripts/check-workspace-policy.sh` - - `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` - -## Test value - -- Gate 1 turns red if the completion is off by one or the grid follows render samples. -- Gate 2 turns red if a jump snaps to the grid or ignores `control_smoothing`, if a written length - cuts a jump in flight, or if the jump length is read only at preparation or one block late. -- Gate 3 turns red if node time ignores `a(n)`: automation would lead its audio by the latency. -- Gate 4 turns red if a seek ramps the lane or reaches the fader at the wrong sample, on either - path. -- Gate 5 turns red if any value depends on the quantum (README blocker B1). -- Gate 6 turns red if render allocates for events, or if a session without automation moves a bit. - -## Dependencies - -- #1646 *Prepare stored fader automation and render it flat*, in the same push. It brings - #1637, #1638 and #1639 and #1312 (the events, the timed operations, the jump-length cell and the - live input D1 drains first). -- #1641 *Give every plan a timeline that carries like a source*. -- #1642 *Seek the timeline and every source in one C ABI call* and #1643 *Seek the timeline - and every source from the browser module export and the headless SDK* (the session seek on each - host). -- Batch: R1, in one push with #1646, #1648 *Classify fader automation edits as carried - rebuilds* and #1649 *Compose VCA offsets with stored fader automation*. - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **Comment anchors gone.** The `crates/session/src/validate.rs` comments of D4 (`:812-822`, - `:842-853`) left with `BUILTIN_AUTOMATION_TARGETS` (#1566); the inert sentence is now in - `docs/SESSION_SCHEMA_V1.md` near `:278-279` and in `validate_automation_target`'s rustdoc - (`validate.rs:868-870`). D4 rewrites those. -- **Composition.** Each event's gain is #1646 D1's with `S` that includes the hand offset (#1650). -- **Must-land group** is #1646, #1647, #1648, #1649 and #1650. - -## Amendment (controls coordinator, batch 13b, from #1638's reviews) - -Checked against `d5b9410df`. This is a requirement for this slice's brief, with the constraints -three #1638 reviews measured; the mechanism is this slice's design, reviewed with it. - -- **The gap (a requirement, D1.6).** #1638 D2 holds a lane's gain during an unmute ramp as well as - a mute ramp: while the stage reports `gain_held`, a timed `RetargetGain` or `SetGain` only - remembers the gain. #1637 commits a `Jump`, a seek `Set` and a grid event that is not held as the - cell's target with `held = false`, and on a curve that is flat afterwards it emits nothing more. - So any gain event the stage only remembers inside a held ramp leaves the lane at the pre-unmute - gain with no end. The requirement: when a held ramp ends with the gain free, the lane moves to the - automation value the cell then holds (composed as D1.4, with `S` and the #1650 hand offset), and - the result does not depend on the quantum (gate 5). Every remembered gain event counts, not only - `Jump` and `Set`: in every hold of length `R` the one grid point in its last 64 samples is not - held by #1637 (its own completion reaches the hold's end) and commits. -- **Constraints the design must meet (measured with the real `crates/automation`).** - 1. D1.2 reports the hold with `hold_until` once, at the block entry whose live drain starts or - restarts the held ramp, and again after an adoption that carries it (`CellState::reposition` - drops it). A refresh at later block entries overwrote a cell `Jump`'s own ramp end and made - `target()` at the ramp end -0.0616 dB at quantum 128 against -0.1955 dB at 100. - 2. The cell is stopped exactly at the ramp end `E`: the lane's `NodeSpan` ends at `E` and a second - span starts at `E + 1`, with any seek step kept in the piece that holds it; the per-lane merge - (D1.3) never pulls an event past `E` before the move at `E`. `CellEvents` borrows the - `CellState`, so `hold_until` cannot be called inside a span. A move built at the block end or - the next block entry instead gave -0.4273 dB at 128 against -0.4535 dB at 100. - 3. The move at `E` holds the grid for its own ramp (`hold_until(completion_of(End, E, J))`), as - after the cell's own `Jump`; otherwise a moving curve's grid events cut it into 64-sample - retargets. - 4. A ramp that ends muted keeps the obligation for the later unmute ramp's end. A live fader write - that clears the hold inside it discharges the obligation at that block entry. - 5. D3's "exactly, with no ramp" does not hold for a seek `Set` inside a held ramp: the stage only - remembers it, and it lands by the move at `E`. - 6. The stage's `fader_operations_stopped` counter (#1638) is summed into this slice's report. - 7. `E` is the first sample at which the stage reports `gain_held` false for the lane. The move at - `E` replaces any gain operation the cell gives at `E`: #1638's sink takes one gain operation per - lane, channel and offset and refuses a second (`FaderOperationError::Duplicate`). -- **Gate 8 (new).** At quantum 128, 100, 64 and 37, the same bits, and the lane never settles at the - pre-unmute gain, in these cases, with `J` the fader row's jump length: - - (a) a `Jump`, and (b) a seek `Set`, inside an unmute ramp on a curve flat after it: from - `E + J - 1` (from `E` when `control_smoothing` is explicit 0) the lane equals the gain composed - from the cell's `target()` exactly, so it settles at that value one fader jump length after `E`; - - (c) a `Jump` whose own ramp ends after `E`, with a block entry between the `Jump` and `E` at one - quantum 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); - - (d) a curve that goes flat inside the last 64 samples of the unmute ramp (no `Jump` or `Set`, - only grid events); - - (e) grid events that are not held lying between `E` and the next block entry at one quantum only - (a `Jump` whose ramp ends before `E`, or a seek `Set` on a moving curve): samples - `[E, E + J - 1]` equal the stage driven directly with one `RetargetGain` at `E` over `J`. - - Test value: red if a remembered event is never moved to at `E`, if the move follows a block entry - instead of `E`, if the lane does not equal the composed `target()` from `E + J - 1` (or from `E` - with `control_smoothing` 0), if D1.2 refreshes `hold_until` inside the hold, or if a grid event - cuts the move's ramp in `[E, E + J - 1]`. -- **#1648** carries the obligation across a swap (see its amendment). - -## Amendment (controls coordinator, batch 15b fold, 2026-10-16) - -- #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). - -## 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. - -## 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. - -## 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. - *(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 - 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. - *(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. -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`. - -## 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). - *(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.** - -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. - -## 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, 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 - `⌊(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). - D1.2 itself is removed (U2 fold 2). - -## 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, 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 - 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 (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 - 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. - -## 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.~~ - 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 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` - 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. - -## 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 deleted file mode 100644 index 00c07a2ca..000000000 --- a/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md +++ /dev/null @@ -1,820 +0,0 @@ -# Classify fader automation edits as carried rebuilds - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answers A2, A10 and A1.8, 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 - -On both hosts, a transaction that adds, changes or removes stored fader automation on a playing -engine takes the rebuild path. Both hosts run the shared commit of `crates/control-plane`: the C -ABI directly, the browser in its Worker (#1382, which lands before #1646). The successor plan holds the new automation program, every owner -carries, the fader lane continues from the value it has, and the revision completes `exact` at -adoption. The response reports `rebuild`. A transaction that changes only the static fader value -of a lane the session automates commits as `model_only`: that value is the lane's fallback, and no -plan renders it while the automation exists. A browser live fader edit on such a lane reaches -the same shared commit through the Worker's apply (#1382) and meets the same rule: the reply reports -`model_only`, and the curve keeps playing. This is one permanent rule in the shared commit, for -both hosts (README A2). Every other automation row stays model-only and inert -until its own rendering slice. - -Today every automation edit is "live with no records": it commits with no plan change, so after -#1646 and #1647 render fader automation, an edit would be acked and never heard. - -## Context - -- **The mask.** `classify_live_delta` (`crates/host-core/src/live_delta.rs:211-215`) copies - `current.automation` into the masked model (`:234-236`). Its documentation says the first issue - that renders stored automation must drop that line (`:181-185`). The numbered steps are - `:149-179`; `LiveRebuild` is `:117-141`. These anchors are those of `6ee64f484`. On `main`, - *Refuse automation on effect parameters that are not block-rate* (#1335, commit `0c19119d0`) has - since added `LiveRebuild::AutomationTarget` and inserted its check as step 4, so the per-track - step is step 5. This draft's step numbers follow #1335's. -- **Fader records.** The per-track step (step 5; step 4 at `6ee64f484`) emits one `FaderDb` per lane - whose gain bits change and one `Mute` per lane whose mute changes (`live_delta.rs:269-306`). -- **The edit path.** *Report each transaction's edit path in its response* (#1313) D2 reports - `rebuild` for `Err(_)` and `model_only` for an empty delta. -- **The rebuild path.** `commit_live` returns `LiveCommit::Rebuild` when the classifier refuses - (`crates/capi/src/runtime/control.rs:1065-1077`); the successor is prepared with - `prepare_host_runtime_with_live_lanes_successor` (`crates/capi/src/runtime/compile.rs:593`). - #1309 moves both into `crates/control-plane`. -- **What carries.** The plan inventory has one row list per carried family - (`PlanStateInventory`, `crates/host-core/src/prepare.rs:555-630`); a successor joins it against - `SuccessorBase` (`:641-647`) and installs a carry program into the graph (`:1791-1793`, - `:1820-1821`). *Carry fader, mute and pan ramps across a plan swap* (#1277) adds the fader and - matrix stages, with move mode at the swap block (#1277 D8). -- **Tests that pin today's rule.** The "automation upsert" case of `model_only_edits` - (`crates/host-core/tests/live_delta.rs:530-545`, the ride at `:508-528`, the removal case at - `:582-588`) and of `model_only_edits` in `crates/capi/src/runtime/live_tests.rs:1419-1470` - (run by `model_only_edits_commit_without_a_plan_rebuild`, `:1481`) use a fader ride (row 5). -- **Docs that say automation edits are model-only:** `crates/capi/include/miso_engine_v1.h:41-43`, - `docs/CONTROL_PROTOCOL_SEMANTICS.md:15`, `docs/C_ABI_V1_QUALIFICATION.md:268-274`. - -## Decisions frozen for this slice - -- **D1. The mask becomes per target row.** - - One function in host-core, `automation_row_renders(target: &AutomationTarget) -> bool`, names - the rows that render. In this slice it is true only for `rack = builtins`, `parameter_id = 5`. - #1646's preparation compiles cells only for the targets it accepts, so the classifier and - preparation read one list. Each later rendering slice adds its row; #1667 adds the last. - - The masked JSON comparison keeps copying `current.automation` (`:234-236`). A new step, after - step 4, #1335 D4's `AutomationTarget` check (which #1645 D5 extends to the builtin rules), - and before the per-track step, compares the entries of rendered rows in `current` and `next`, - in table order, by canonical bytes. Any difference returns a new `LiveRebuild::Automation`. - The numbered rules and the model-only paragraph (`:181-185`) are rewritten to say so. -- **D2. The rebuild carries every owner** (A1.8; D15-7). - - **The stage.** This slice extends the carry predicate of #1277 D4 (which lands first): gaining, - losing or changing stored automation is neither a prepared value nor a change of control kind, - so the fader stage carries. #1277 itself is not amended. - - **The cell.** A cell's whole event state (#1637 D2: cursor, current target bits, in-flight - ramp end, held grid, last seek boundary) carries by its stable address `(strip ID, parameter ID, - lane)`. #1646 D1 keeps that state in the fader bank stage beside the lane's ramp state, so - the fader stage's carry program under #1277 moves it, with the ramp state, at the swap block in - move mode (#1277 D8). The stage's inventory rows gain the cell addresses, and the join pairs - 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. (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"). - - **A cell lost.** A lane whose entry the edit removes gets a release cell in the successor: its - static effective value (the `next` fader composed with the VCA offsets, as preparation bakes - it), no table. At adoption it takes the carried state; if the carried target differs from the - static value, it jumps to it over the fader jump length. Then it is inert. No control-plane - record is written: render alone knows the carried target. -- **D3. A static fader edit on an automated lane gives no record** (A2). - - One predicate, `automated_cell(model: &SessionModel, address: CellAddress) -> bool`, built on - the same enumeration #1646's preparation compiles cells from. The later slices reuse it. - - The per-track step (step 5) emits no `FaderDb` for a lane that `next` automates. After D1, - `current` automates the same lanes. Since *Deliver value-only VCA edits to the running C ABI - plan* (#1247) D2 that step diffs the effective faders, so the rule covers a VCA move that - reaches an automated lane too; #1649 makes that move reach the lane's offsets cell. Mute - records are unchanged. A transaction that changes only such a value yields an empty delta and - commits `model_only` (#1313 D2); D2's release cell reads the value when the automation goes. - - **One rule, both hosts.** The rule lives in the shared classifier, which both hosts' commits - call (#1309; the Worker's apply, #1382, for the browser). No host refuses such an edit and no host has its own - admission for automated lanes. README A2 and finding F9 give why `model_only` is not - "acknowledged with no effect" in #1315's sense: the edit sets the committed document's fallback - value, the response names the path, and the value is rendered when the automation goes. -- **D4. Docs.** The sentences that say automation edits are model-only, in the text that P1 - (#1627 D5) and *Report each transaction's edit path in its response* (#1313, which rewrites - the header paragraph) leave in the C ABI header, `CONTROL_PROTOCOL_SEMANTICS.md` and - `C_ABI_V1_QUALIFICATION.md` (on `6ee64f484`: `miso_engine_v1.h:41-43`, - `CONTROL_PROTOCOL_SEMANTICS.md:15`, `C_ABI_V1_QUALIFICATION.md:268-274`), say: a fader automation edit rebuilds and carries; a - static fader edit on an automated lane is model-only; the other rows stay model-only until they - render. -- **D5. The acked-batch question: can an ack ever precede a drop? No.** A rebuild acks only after - its successor is prepared, and the carry moves every value. No queue is added. - -## Deliverables - -1. D1 and D3 in `crates/host-core/src/live_delta.rs`, with the classifier tests. -2. D2: the inventory rows, join, release cells and carry-program move. -3. D4. - -## Authorized paths - -- `crates/host-core/src/live_delta.rs`, `crates/host-core/tests/live_delta.rs` -- `crates/host-core/src/prepare.rs` (the inventory rows, the join and the release cells only), - `crates/host-core/tests/successor_swap.rs` -- `crates/graph/src/lib.rs`, `crates/graph/src/runtime.rs`, `crates/builtins-compiler/src/lib.rs` - (the fader stage carry's move of cell event state only; stream A's files, sequenced by root) -- `crates/control-plane/src/` (the commit path; #1309 is on `main` through #1646's - dependency), `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/src/tests.rs` (gate 5) -- `crates/capi/include/miso_engine_v1.h` (comment text), `docs/CONTROL_PROTOCOL_SEMANTICS.md`, - `docs/C_ABI_V1_QUALIFICATION.md` - -## Non-goals - -- Rendering fader automation (#1646 and #1647). - VCA offsets on an automated member (#1649). The jump length cell (#1639). Mute, pan, matrix, - trim, polarity, filter and effect rows (#1651 to #1667). -- Options B, C and D of OQ1. The browser's Worker commit itself (#1382, which lands before - #1646). - -## Hazards - -- **Superseded cases.** The "automation upsert" and removal cases named in Context now rebuild. - This slice moves them to a parametric EQ band-gain entry, a valid `Block` target whose row stays - masked until #1667, the last row to leave the mask. So no slice between #1648 and #1667 touches - them, and #1667, after which every automation edit is a rebuild, deletes them. -- **Order of the two automation checks.** #1645 D5 routes a refusable builtin entry to - `LiveRebuild::AutomationTarget`. D1's check runs after it, so a refusable fader entry keeps that - variant and its refusal; gate 1 holds the order. -- **A redundant jump moves bits.** The adoption rule compares bits; a jump to the same target - re-enters the ramp kernel. Gate 3 holds it. - -## Objective gates - -1. **Classifier** (`crates/host-core/tests/live_delta.rs`, new tests). Adding, changing or removing - a fader entry gives `Err(LiveRebuild::Automation)`. A mute (row 6) entry change gives - `Ok` with no records. A static `left_db` change on a lane a `left` fader entry automates gives - `Ok` with no records; the same change on the right lane gives one `FaderDb` for `Right`. A - static change together with an automation change gives `Automation`. A fader entry in unit - `linear` gives `AutomationTarget` (#1645 D5), not `Automation`. -2. **A changed curve rebuilds and carries** (`crates/capi/src/runtime/live_tests.rs`, new). A - playing `long_session` with a linear fader ride on track 0 takes an `UpsertAutomation` that - changes the ride. The response path is `rebuild`; the watermark publishes `EXACT` at adoption - (#1314); no source is reseeked. From the completion sample of the first grid ramp that starts - after the adoption jump ends, every block is bit-identical to a fresh plan of the new session, - session-seeked (#1642) to the adoption block's timeline sample and fed the same PCM. -3. **An unchanged curve gives no jump** (same file). A transaction that adds a muted track and keeps - the ride: every block from the swap is bit-identical to the uninterrupted render of the old - session. -4. **Removal** (same file). Removing the ride: from the end of the release jump, every block equals - a fresh plan of the new session (static fader) from the same timeline sample. -5. **Static edit, both hosts** (same file; `hosts/host-web/src/tests.rs`, new). A `left_db` change - on the automated lane: path `model_only`, the same epoch, every block bit-identical to the - uninterrupted render. In the browser, a live fader edit on the automated lane, through the - Worker's apply, replies `path: "model_only"` with the revision advanced by 1, - and every block is bit-identical to the uninterrupted render; the same edit on the other - lane replies `live`. -6. **Realtime.** The swap block allocates and frees nothing (`bench_support::alloc` thread counters - in `crates/host-core/tests/successor_swap.rs`, statics warmed). -7. **Commands:** - - `cargo test --locked -p host-core --features host-core/test-support --test live_delta`, - `cargo test --locked -p host-core --features host-core/test-support --test successor_swap`, - `cargo test --locked -p capi`, `cargo test --locked -p graph --features test-support`, - `cargo test --locked -p host-web --features host-web/test-support` - - the workspace debug leg (`test-debug-a` in `.github/workflows/qualification.yml`) - - `cargo build --locked --release -p audit -p capi && ./target/release/audit capi` (all - violation counts 0) - - `bash scripts/check-capi-abi.sh`, `bash scripts/check-host-core-policy.sh`, - `bash scripts/check-graph-policy.sh`, `bash scripts/check-realtime-policy.sh`, - `bash scripts/check-workspace-policy.sh` - - `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` -8. **No rendered bit moves** for a session with no stored automation: `audit capi` reports the same - `pcm_digest` at base and head (PR evidence), and the browser legs pass with unchanged digests. - -## Test value - -- Gate 1: red if the mask still hides fader entries, hides nothing (a mute entry would rebuild), or - if a static edit on an automated lane still emits a record that fights the curve, or if D1's check - runs before the refusal route (a refusable entry would report `Automation`). -- Gate 2: red if the cell does not carry (the lane restarts), the cursor is not repositioned, or - the adoption jump is missing or has the wrong length. -- Gate 3: red if adoption emits a jump to an unchanged target. -- Gate 4: red if a removed entry leaves the lane on its last automated value. -- Gate 5: red if a static edit on an automated lane rebuilds or retargets, or if either host has a - path that writes a live record onto an automated lane. -- Gate 6: red if the carry of cell event state allocates or frees on the swap block. - -## Dependencies - -- #1647 *Render moving stored fader automation, seeks and latency* (same batch). It brings - #1646's cells and event state, #1637's reposition, #1642's session seek (gate 2's - reference) and #1645's D5 route (which runs first). -- *Carry fader, mute and pan ramps across a plan swap* (#1277): the stage carry D2 extends. -- *Report each transaction's edit path in its response* (#1313). -- *Publish an applied-revision watermark and complete edits asynchronously* (#1314), for `EXACT`. -- *Refuse automation on effect parameters that are not block-rate* (#1335): its step 4 and - `LiveRebuild::AutomationTarget`. -- Batch: R1, in one push with #1646, #1647 and #1649 (README "Must-land-together groups"): D3 drops - the `FaderDb` that a VCA ride gives an automated member, and #1649 adds the offsets-cell write - that makes the ride heard. *Admit browser live edits in the Worker through the committed model* - (#1382) arrives through #1646. - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **The classifier changed shape** (decision 17 C6, #1554). `classify_live_delta` masks control - values with `controls::mask_values`, diffs rows with `controls::changed_controls` and lowers by - family (`crates/host-core/src/live_delta.rs:240-300`); the stored automation is masked whole at - `:359-361` and re-checked by the row rule at `:372-376`. D1's predicate becomes - `controls::automation_row_renders(address: &ControlAddress) -> bool` (classification rules live in - `controls`, C1), true only for builtins control 5 in this slice. D1's new step compares the - entries whose rows render; `LiveRebuild` gains `Automation` beside `AutomationTarget`. -- **D3 is split by OQ1 = C.** For the fader row, the static edit is no longer `model_only`: - #1650 makes `SET_CONTROL fader_db` on an automated lane write the lane's offset (live). D3's rule - ("a static edit of an automated cell gives no record and commits `model_only`") stays the rule - for the rows where automation wins, and its predicate `automated_cell` (in `controls`, over - `ControlAddress`) is the one later slices use (#1654, #1657, #1658 for polarity, #1660, #1665, #1667). Gate 1's - "static `left_db` change ... gives `Ok` with no records" and gate 5 move: the fader case becomes - #1650's gate 2; the first automation-wins row that renders (pan, #1657) takes gate 5's - `model_only` case on both hosts. -- **VCA rides.** #1247's step is now the effective diff (`EffectiveDiff`, C6 step 3); D3's note on - VCA moves reads against it. -- **The browser** commits through the same `SessionState` (#1562a). An automation edit is a rebuild, - which the browser refuses with `web.apply.rebuild_pending` until #1562b; so #1562b is a dependency - of batch R1, and gate 5's browser leg runs after it, through - `hosts/host-web/tests/control_parity.rs`. -- **Moved files.** `crates/capi/src/runtime/{control.rs,compile.rs}` are in `crates/control-plane/src/`. - The tests that pin today's model-only automation edit are `model_only_edits` - (`crates/host-core/tests/live_delta.rs:1125-1154`) and its C ABI twin in - `crates/capi/src/runtime/live_tests.rs`. Realtime policy as in #1637's amendment. -- **Dependencies.** #1277, #1313, #1314 and #1335 have landed; add #1562b. Must-land group: #1646, - #1647, #1648, #1649, #1650. - -## Amendment (controls coordinator, batch 13b, from #1638's reviews) - -- **D2 also carries #1647 D1.6's state.** The per-lane, per-channel obligation to move to the - automation value at a held ramp's end carries with the cell's event state, and the successor - re-reports a carried held ramp to the cell with `hold_until` at adoption (`CellState::reposition` - drops it). -- **Release cells too.** A release cell (D2, "A cell lost") takes the same carried obligation and - the same `hold_until` report at adoption. Its jump to the static value is a gain event: inside a - held ramp the stage only remembers it, so the obligation must move the lane to the static value at - the ramp's end, as #1647 D1.6 does for a carried cell. -- **Seeding a cell's target.** D2 starts a gained cell from the carried stage target and a release - cell from the carried state, and #1646 D2 starts each cell at its prepared value. #1637's - `CellState` (`crates/automation/src/events.rs:106-155`) has no way to set its current target from - outside: `new` leaves it empty and `reposition` keeps it. Authorized paths gain - `crates/automation/src/events.rs` (one seeding method on `CellState` that sets the current - target, and its test), because no other path can give a cell the target these rules start it - from. - -## Amendment (controls coordinator, batch 13b, from #1639's review; root ruling 2026-10-15) - -- **Jump lengths follow a rebuild.** #1648 D1 makes every automation edit on a row that renders - a rebuild, which seeds the plan's jump-length cell from the committed model (#1639 D4). A row that - does not render yet reads no length, and its rendering slice (#1651, #1657, #1658) makes its - edits rebuilds the same way, so the cell is right from the first block any row reads it. -- **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). - -## 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. - -## 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. - -## 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. - -## U3 fold (batch 17) - -Fold of the U3 review, part U3a (render and carry), on `codex/controls-batch-17k` over -`86271e096`, committed as `9dcf288de`. **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). - -**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 -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. (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. - -## 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. - -## Split (root ruling B17-R13, batch 17) - -- #1648 closes on its passing gates. The in-flight Reposition length case moves to - `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). #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 - (`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 #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. -- 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. - -## 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. - `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 - 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/1649-compose-vca-offsets-with-stored-fader-automation.md b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md deleted file mode 100644 index 8af10db0d..000000000 --- a/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md +++ /dev/null @@ -1,450 +0,0 @@ -# Compose VCA offsets with stored fader automation - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answers A1.5 and A2, 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. The offsets cell's layout is root's decision in -README finding F16 (layout 4: the offsets sum first). - -## Product outcome - -A producer automates a vocal fader; a fan rides the "vocals" VCA, in the browser or through a C ABI -host. The VCA move is heard on top of the ride: the member lane follows its curve plus the VCA's -offset, and after the fader jump ramp the output equals a plan prepared with that VCA offset and -the same automation, bit for bit, whatever the number of VCAs that reach the lane. On both hosts -the move goes through the shared commit's VCA row (*Deliver value-only VCA edits to the running C -ABI plan*, #1247; the browser through #1382), which writes the lane's offsets cell instead of a -fader record, and the path is `live`. Every other VCA edit that #1247 makes live stays `live` on an -automated member too: a change of a VCA's members, nested VCAs included, and adding or removing a -VCA. A member whose fader is not automated keeps today's records. This slice moves no rendered bit -of a session without stored automation (#1646 D6 is the one order change). - -## Context - -- **The one composition.** Today `vca_effective_db(member_db, offsets)` sums in `f64`, the - member's own value first, then each offset in the order given, clamps to `[-144, 24]` and rounds - once to `f32`; with no offset it returns the member bit for bit (`crates/session/src/vca.rs:19-34`). - `effective_strip_faders` passes each strip's reaching VCAs' offsets in ascending VCA-ID order - (`:96-120`), and `crates/session/tests/vca_composition.rs:79-82` pins the order. #1646 D6 - (README F16, root's decision) changes it: `vca_offsets_sum` sums the offsets first and `vca_compose_db(member, - S)` adds the member, on every path. -- **#1646** creates and seeds the offsets cell (#1646 D1): for each automated fader lane, - in every session, a #1312 cell of three words, the lane's `S` as one `f64` and a ramp word; `S` - is `+0.0` when no VCA reaches the lane. #1646 D1 shows that the cell keeps the static gain - bits. Render computes each event's gain as `checked_fader_gain(vca_compose_db(v, S))` from the - cell's newest slot. Nothing writes the cell after preparation yet. -- **The shared VCA row.** #1247 D2: the classifier's strip step compares `effective_strip_faders()` - of `current` and `next`, lane by lane, and emits one `FaderDb` per lane whose effective gain bits - change. #1247 makes VCA rides, mutes and membership live on the C ABI (D15-6), because the C ABI's - render plane holds no VCA state. After #1382 a browser VCA edit reaches the same shared commit - through the Worker's apply and takes the same row. -- **#1648 D3** makes that step emit no `FaderDb` for a lane that `next` automates, so until this - slice a VCA move does not reach an automated member. -- **The cell primitive.** #1312 D1: a cell has three slots of words and a sequence; the writer fills - every word of its back slot, then publishes; render reads the newest completed write in one pass - and never sees a mixed slot. - -## Decisions frozen for this slice - -- **D1. The composition is #1646 D6's.** This slice calls `vca_offsets_sum` and - `vca_compose_db` and changes neither. A flat curve at `v` renders the bits a static `v` prepares, - however many VCAs reach the lane. -- **D2. The offsets cell is #1646's.** This slice adds only the write that rewrites it, a whole - slot at a time (D4), and the jump at the next block entry (D3). -- **D3. Render.** - - At each fader event, the gain is `checked_fader_gain(vca_compose_db(curve, S))`, with `S` from - the cell's front slot (the newest write render has read), as #1646 D1 composes it. - - At block entry, when the cell is dirty, render reads the new slot. If the composed gain at the - jump's completion sample differs in bits from the lane's current gain target, the lane takes a - jump at the block's first sample over the slot's ramp word (D4): the length that #1247 D5 gives - the same VCA edit's `FaderDb` records on members that are not automated. Otherwise nothing - happens. The comparison is on the gain, never on the dB sum, so an `S = +0.0` that turns a - `-0.0` value into `+0.0` causes no jump. Grid events during that jump are held - (README "Held events"). -- **D4. The shared commit writes the cell.** - - In #1247's strip step, for a lane that `next` automates, the classifier computes the lane's `S` - for `current` and `next` (`vca_offsets_sum` over each model's reach offsets for the lane's - channel, in ascending VCA-ID order, `+0.0` for an empty reach). If the bits differ, it emits one - offsets-cell write that carries `next`'s `S` and the ramp word - `LiveRamps::resolve(VcaFader, the edit's own ramp)` (*Carry an optional per-edit ramp length on - live session edits*, #1394 D5-D6; #1247 D5), so one VCA edit ramps its automated and its plain - members over one length. A ride, a membership change, a nested-VCA change, adding a VCA and - removing one are all such writes: each changes only `S`, and the cell exists on every automated - fader lane (#1646 D1). #1312's publication rule needs every word of the slot, so the writer - never writes one word alone. The path is `live`; no VCA edit rebuilds. - - For a lane that is not automated, #1247's `FaderDb` records are unchanged. -- **D5. Memory and work.** Each offsets cell holds three 4-byte words in each of #1312's three - slots: 36 bytes per automated fader lane, whatever the session's VCA count, plus the cell's fixed - sequence and index words (#1646 D4 charges them). Render adds one `f64` (`S`) per event; - nothing depends on song length or on the VCA count. -- **D6. Out of scope here:** a VCA mute on a member whose mute is automated (#1651). -- **D7. The acked-batch question: can an ack ever precede a drop? No.** The commit checks the whole - transaction before the first cell write, and a cell write cannot fail; a replaced slot is in the - committed state and counted (#1312 D2). - -## Deliverables - -1. D3: render's dirty read at block entry and the jump. -2. D4 in the shared classifier and the commit path: the `S` comparison and the cell write. - -## Authorized paths - -- `crates/host-core/src/live_delta.rs` (the strip step's automated-lane branch), - `crates/host-core/tests/live_delta.rs`, `crates/host-core/tests/vca_live.rs` -- `crates/control-plane/src/` (the offsets-cell write) -- `crates/builtins-compiler/src/lib.rs` (the fader bank's dirty read of the offsets cell and the - jump only) -- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/src/tests.rs`, - `crates/control-plane/tests/vca_automation_realtime.rs` (new) -- `crates/control-plane/Cargo.toml` (a `bench-support` dev-dependency for that binary, only if an - earlier slice has not added it), `Cargo.lock` - -`crates/session/src/vca.rs` is called, never changed (#1646 D6 changes it). - -## Non-goals - -- VCA automation (out of scope by decision 13, `docs/rulings/submix-strips-sends-and-vca-2026-10-02.md:129`). -- A VCA mute on an automated mute lane (#1651). -- Any change to the composition beyond #1646 D6. - -## Hazards - -- **A redundant jump moves bits.** D3 compares bits before it jumps; D4 writes only on a change. -- **Batch R1, one push with #1646, #1647 and #1648** (README "Must-land-together groups"). #1648 - D3 drops the `FaderDb` of an automated member; without this slice an acknowledged VCA ride on that - member is not heard. - -## Objective gates - -1. **Automated member, both hosts** (`crates/capi/src/runtime/live_tests.rs` and - `hosts/host-web/src/tests.rs`, new). Track 0 has a linear fader ride and is a member of VCA `v`; - track 1 is a member with no automation. A transaction (C ABI) and a browser live VCA edit through the Worker's apply - move `v` by -6 dB on a playing engine: path `live`. From the completion sample of the first grid - ramp after the jump, the output equals a plan prepared with `v` at -6 dB and the same - automation, fed the same PCM from frame 0. Track 1 receives exactly one `FaderDb`, as today. The same move - with its own ramp of 256 samples: track 0's jump and track 1's `FaderDb` ramp both last 256 - samples. -2. **Many moves, one block** (browser, same file). Ten browser live edits of `v`'s offset before one render: the - output equals a twin that made only the last. Three nested VCAs reach track 0, declared in an - order that is not their ID order and nested so that the nesting order is not their ID order - either. Their offsets are `24`, `-24` and `2e-30` in ascending VCA-ID order. A move of the - third VCA to `1e-30` writes the `S` that a fresh plan of the edited session computes, bit for - bit: the sum in ID order, `(24 + -24) + 1e-30 = 1e-30`, has other `f64` bits than an order that - adds `1e-30` before `-24`, which gives `0.0`. With two offsets, no order can change the sum - (`f64` addition is commutative), so the case needs three. -3. **No jump for an unchanged offset** (same files). A move of a VCA that does not reach track 0, or - that moves `v` to its current value, writes no cell and moves no bit. -4. **Reach change and VCA count change** (C ABI and browser, same files). Each of these is path - `live`, and from the end of the jump the output equals a fresh plan of the new session at the - same timeline sample: adding track 0 to a second existing VCA; adding that VCA as a member of `v` - (nested); adding a new VCA (`UpsertVca` of a new ID) that reaches track 0; adding the first VCA to - a session that had none; removing a VCA that reaches track 0. -5. **Classifier** (`crates/host-core/tests/live_delta.rs`, new). The move of gate 1 gives one - offsets-cell write for track 0's lanes and one `FaderDb` per lane of track 1. -6. **Realtime** (`crates/control-plane/tests/vca_automation_realtime.rs`, new integration binary in control-plane, the crate that - holds the shared commit, `control_plane::SessionState`, #1309 D1-D9. A host-core binary cannot - reach the commit without a dev-dependency cycle, since control-plane depends on host-core. - `scripts/check-bench-policy.sh:257-280` allows a `bench-support` dev-dependency only in a - `crates/` manifest, so no host-web binary can link it. The binary links `bench_support::alloc` - and calls `assert_installed()` first). It drives the script through control-plane's shared - commit and host-core's render session, the code the browser Worker (#1382) and the C ABI both - run. For gate 1's script on the render - thread: `allocations == 0 && frees == 0` around every render call after warm-up. -7. **Commands:** - - `cargo test --locked -p session` (`vca_composition.rs` as #1646 leaves it), - `cargo test --locked -p host-core --features host-core/test-support`, - `cargo test --locked -p capi`, `cargo test --locked -p host-web --features host-web/test-support`, - `cargo test --locked -p builtins-compiler --features test-support` - - `cargo test --locked -p control-plane --features test-support` - - the workspace debug leg (`test-debug-a` in `.github/workflows/qualification.yml`) - - `bash scripts/check-web-audioworklet.sh`, `bash scripts/test-web-audioworklet.sh` - - `bash scripts/check-host-core-policy.sh`, `bash scripts/check-realtime-policy.sh`, - `bash scripts/check-workspace-policy.sh` - - `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` -8. **No rendered bit moves** for any session without stored automation, VCA sessions included: - `cargo build --locked --release -p audit -p capi && ./target/release/audit capi` gives the same - `pcm_digest` at base and head (PR evidence), and the browser legs pass with unchanged digests. - -## Test value - -- Gate 1: red if a VCA move does not reach the offsets cell (the lane stays on the old - composition), if the hosts differ, or if a non-automated member loses its record. -- Gate 2: red if render reads an older slot, the writer writes one word alone (a slot mixes the - halves of two sums), or the classifier sums nested VCAs in another order than preparation. -- Gate 3: red if an unchanged offset still writes the cell or retargets the lane. -- Gate 4: red if any VCA edit on an automated lane rebuilds, if a lane no VCA reached has no cell to - write when a VCA is added, or if a removed VCA leaves its offset in `S`. -- Gate 5: red if the classifier emits a `FaderDb` that the next curve event would contradict. -- Gate 6: red if the cell read or the composition allocates on render. -- The composition order and the no-VCA rule are #1646's gates 1 and 6. - -## Dependencies - -- #1646 *Prepare stored fader automation and render it flat* (the offsets cell it creates and - seeds). It brings *Admit browser live edits in the Worker through the committed model* (#1382), - which brings #1247's shared VCA row. -- #1648 *Classify fader automation edits as carried rebuilds* (the carry and the strip step's - automated-lane rule). -- #1639 *Hold the automation jump lengths in a plan cell* (the `fader` jump length of the - adoption jump). *Carry an optional per-edit ramp length on live session edits* (#1394), whose - `resolve` gives the ramp word, arrives through #1382. -- Batch: R1, in one push with #1646, #1647 and #1648. - - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **Composition** is `controls::effective` over `session::VcaSum` (#1646's amendment); D4's - "#1247's strip step" is the classifier's effective diff (`EffectiveDiff`, - `crates/host-core/src/live_delta.rs`, C6 step 3). -- **`S` includes the hand offset** (OQ1 = C, #1650 D6): `S = offset + Σ reaching VCA offsets`, - the lane's own offset first. The cell, its write and the jump rule are unchanged; #1650 reuses them. -- **VCA automation** is no longer out of scope by decision 13: decision 17 computes `automatable` - from the row, and #1566 accepts a VCA `offset_db` or `mute` target (the fixture holds `vca-ride`). - No slice renders it yet (README F20); if root files one, it composes into this slice's `S`. -- **Moved files.** `crates/host-core/tests/vca_live.rs` is gone (use `crates/host-core/tests/vca.rs`); - `hosts/host-web/src/tests.rs`'s VCA oracles were deleted (#1565): the browser leg goes through - `hosts/host-web/tests/control_parity.rs`. -- **Must-land group** is #1646, #1647, #1648, #1649 and #1650. - -## Amendment (controls coordinator, batch 10, 2026-10-14) - -- **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). - -## 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. - -## 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` - (#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 - 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. -- #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. - `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`). - -## U3 fold (batch 17) - -Fold of the U3 review, part U3a, on `codex/controls-batch-17k` over `86271e096`, committed as `9dcf288de`. **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. -- 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 #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 deleted file mode 100644 index 601c81abb..000000000 --- a/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md +++ /dev/null @@ -1,644 +0,0 @@ -# Store a hand move on an automated fader or trim as a dB offset - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), owner answer OQ1 = C (2026-10-14, README "Owner answers"), under decision 17 -(`docs/rulings/one-control-abstraction-2026-10-13.md`, C2, C3, C5, C6 and C10). A draft for root to -file. Written by the controls coordinator on 2026-10-14. Code anchors verified on `12d4a4f9d` -(batch 9 head). Batch R1, in one push with #1646, #1647, #1648 and #1649. - -## Product outcome - -A producer automates a vocal fader. A fan, or the producer in the browser mixer, moves the same -fader by hand. On every host the move is heard on top of the ride: the lane plays the curve plus a -stored dB offset, as the trim modes of Logic Pro, Pro Tools and Cubase do (README [S3], [S4], -[S6]). The move is one `SET_CONTROL` on the row the move addresses today (`fader_db`, builtins -control 5), it is live (one cell write, no rebuild), and the offset is saved in the session: in its -canonical JSON, on the wire and in the control list. A later re-authoring of the curve -(`SetAutomationSegments`) keeps the offset. The same rule holds for the input trim (`trim_db`, -builtins control 2) from the slice that renders trim automation (#1658). The owner's other two -rules are not in this slice: a hand mute ORs with the automated mute (#1651, amended), and on -every other row automation wins (#1648 D3). - -## Context - -- **The owner answer.** README "Owner answers (2026-10-14)": OQ1 = C. A hand move of an automated - fader dB or input trim dB adds a stored dB offset to the curve; mute ORs with the automated mute; - on other rows automation wins. -- **The stored automation.** `Automation { id, target: ControlAddress, segments }` - (`crates/session/src/model.rs:969-977`). JSON keys `id`, `target`, `segments` - (`crates/session/src/visit.rs:143`, walk `:299`; parse `crates/session/src/parse.rs:1569`); wire - message `automation` with fields 1-3 (`crates/protocol/src/schema.rs:1275-1287`). The edits - `UpsertAutomation` (`0x0600`), `RemoveAutomation` (`0x0601`), `SetAutomationTarget` (`0x0602`) and - `SetAutomationSegments` (`0x0603`) (`crates/protocol/src/model.rs:81-88`, `:316-327`). -- **One row rule.** `controls::automation_diagnostics` (`crates/controls/src/automation.rs:72`) - judges every automation against the row its target names (#1566 D6). Preparation - (`crates/host-core/src/prepare.rs:1890`), the classifier (`crates/host-core/src/live_delta.rs:372-376`) - and `session-validator` (`tools/session-validator/src/lib.rs:396`) call it. -- **The row binds the model.** Builtin rows bind to their model field through one accessor arm per - builtin ID in `controls` (decision 17 C3; `crates/controls/src/builtin_rows.rs`). `fader_db` and - `trim_db` are per-lane, live, domain `[-144, 24]` dB (`crates/builtins/src/lib.rs:509-530`, - `:591-607`). `controls::mask_values` and `controls::changed_controls` read every row through its - accessor (`crates/controls/src/mask.rs`, `diff.rs`), and the classifier lowers a changed row by its - family (`crates/host-core/src/live_delta.rs:240-300`). -- **The control list.** A control row carries `automatable` (field 31), `active` (33) and `value` - (34) (`crates/protocol/src/schema.rs:358-433`). No field says whether stored automation drives the - row. -- **The fader composition.** #1646 D1 gives every automated fader lane an offsets cell that - holds `S`, the `f64` sum of the reaching VCA offsets, and composes each event as - `checked_fader_gain(vca_compose_db(v, S))`; #1649 writes the cell on a VCA edit. A lane no VCA - reaches holds `S = +0.0`. -- **The fallback rule of A2.** #1648 D3 commits a static edit of an automated cell as - `model_only`: the fallback value, stored and not heard. Under OQ1 = C that rule stays for every - row except the fader and trim dB rows (this slice) and the mute row (#1651). - -## Decisions frozen for this slice - -- **D1. The stored field.** `Automation` gains `offset: AutomationOffset { left_db: f32, right_db: - f32 }`, required in every entry (the V1 grammar has no optional key; decision 17, "How to add a - control", step 2.1): - - JSON: `"offset": { "left_db": , "right_db": }`, key ID 4 in the automation walk, - appended after `segments`, with the record's two keys IDs 1 and 2; the canonical writer, the - parser, `keys::automation`, the wire message `automation` (field 4, a required nested message - `automation_offset` with two required `F32` fields 1 and 2), its encode and decode, the - schema-hash re-pin, and #1568's equal-key test if it has landed. - - The SDK's key tables (generated after #1569) and its session builder (`automation()` takes an optional - `offset`, default both lanes `0`). Every checked-in document that carries automation gains - `"offset": {"left_db": 0.0, "right_db": 0.0}`, rewritten by the engine's canonical writer. - - The offset of a lane belongs to the entry that drives that lane. An entry whose `lane` is `left` - has `right_db = +0.0`, and the reverse; a `both` entry holds one offset per lane. So a - lane-granular hand move on a `both` entry needs no entry split. - - `RemoveAutomation` removes the offset with the entry. `SetAutomationSegments` keeps it. - `SetAutomationTarget` keeps it, and D2 judges it against the new row. -- **D2. The row rule.** `controls::automation_diagnostics` gains two checks, with the session paths - `$.automation[id=].offset.left_db` and `.right_db`: - - `control.automation.offset` (new, one code of the control set): a non-zero offset on a row that - takes none. Only `fader_db` and `trim_db` take an offset (one `takes_offset` rule in `controls`, - by builtin ID); a lane the entry does not drive must hold `+0.0`. - - `control.domain`: an offset that is not finite or lies outside `[-144, 24]` dB, the VCA offset - domain (`crates/session/src/validate.rs`, VCA offsets). - The session crate checks nothing new: it cannot see rows. -- **D3. The write is the row.** On a lane whose fader (or, from #1658, trim) automation renders - (#1648 D1's `automation_row_renders`), the row's accessor reads and writes that lane's offset - in the entry that drives the lane, not the static field. So: - - `SET_CONTROL { address: fader_db of (strip, lane), value: v }` sets that lane's offset to `v`. - `controls::check` applies the row's own domain, `[-144, 24]`, which is the offset domain of D2. - A `both` write sets both lanes' offsets, as decision 17's lane rule says. - - `CONTROL_STATE_GET` and the control list report the offset as the row's `value`, so a read-back - equals the write, and the default `0` is "no trim", as a DAW fader in trim mode shows it. - - The static fader value of an automated lane (its fallback) is not reachable by `SET_CONTROL` - while the automation exists. A host changes it in one transaction: `RemoveAutomation`, the - `SET_CONTROL`, then `UpsertAutomation`; edits apply in order to the running model (decision 17 - C5). A document replacement also sets it. - - On a lane whose automation does not render yet (trim before #1658), the accessor keeps the - static field and the stored offset is inert, as every not-yet-rendered row is (A10). -- **D4. The list says so.** The control row gains `automated BOOL` (field 35 of `control_entry`, - optional on the wire, written on every control row): true when a rendered stored automation drives - the row's lane. It is a descriptor field in decision 17's sense, so an automation edit that changes - it advances `structure_epoch` (C4). The SDK's generated control type gains it (#1550). -- **D5. The classifier.** Because the row's accessor reads the offset, `controls::mask_values` - copies the offset with every other control value, and step 1 of `classify_live_delta` does not - see an offset change as a change of the automation table. Step 3 sees a changed `fader_db` value - on an automated lane, and the strip-level lowering writes the lane's offsets cell (#1649 D4) - with the new `S` and the ramp `LiveRamps::resolve(Fader, the edit's ramp)`, instead of a `FaderDb` - record. The path is `live`. An `UpsertAutomation` whose only change is the offset is classified the - same way. A change of segments or target stays a rebuild (#1648 D1). -- **D6. Render.** The hand offset joins #1646's sum: `S = offset + Σ reaching VCA offsets`, in - `f64`, the lane's own offset first, then the VCA offsets in ascending VCA ID. Each fader event is - `checked_fader_gain(vca_compose_db(curve, S))`, unchanged; a cell change jumps at the next block - entry over the cell's ramp word (#1649 D3). An offset of `±0.0` and no VCA give the gain bits of - no offset (`f64(v) + (±0.0)` differs from `v` only in the sign of a zero, and `db_gain(±0) = 1`). - All per-sample work stays in the existing unfused fader kernels; the offset adds one `f64` - addition per event. Trim's render half (its own offsets cell, the same composition with - `checked_trim_gain`) is #1658's, amended. -- **D7. Mute and other rows.** Not in this slice. #1651 (amended) ORs the static mute into the - automated mute. On every other row #1648 D3's rule stays: a static edit of an automated cell is - stored, not heard, and commits `model_only`. -- **D8. The acked-batch question: can an ack ever precede a drop? No.** The write is checked whole - before the first cell write (decision 17 C6), a cell write cannot fail, and the committed model - holds the offset. - -## Deliverables - -1. D1 in `crates/session`, `crates/protocol`, the SDK and every document that carries automation. -2. D2-D4 in `crates/controls` and the control-list codec. -3. D5 in `crates/host-core/src/live_delta.rs`; D6 in the fader bank's event composition and the - offsets-cell write of #1649. -4. `docs/SESSION_SCHEMA_V1.md` (the automation section: the offset, its rule and the write rule) and - `docs/CONTROL_PROTOCOL_REGISTRY.md` (field 35). - -## Authorized paths - -- `crates/session/src/{model.rs,parse.rs,visit.rs,canonical.rs,lib.rs}`, `crates/session/tests/**` -- `crates/protocol/src/{schema.rs,session_wire.rs,message_wire.rs,control_rows.rs}` and their tests -- `crates/controls/**` -- `crates/host-core/src/live_delta.rs`, `crates/host-core/tests/live_delta.rs` -- `crates/builtins-compiler/src/lib.rs` (the fader event composition only) -- `crates/conformance/src/protocol_corpus.rs` (the re-pin), `fixtures/session/**`, - `fixtures/session-canonical/**`, `.claude/skills/author-session/{SKILL.md,worked-session.json}` -- `sdk/src/core/session.ts`, `sdk/src/internal/session-json.ts`, `sdk/src/generated/*` - (regenerated), `sdk/test/**` -- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs` -- `docs/SESSION_SCHEMA_V1.md`, `docs/session-v1.schema.json`, `docs/CONTROL_PROTOCOL_REGISTRY.md` - -## Non-goals - -- The mute OR (#1651) and the trim render (#1658). -- An offset on any other row: owner answer C names only the fader and the input trim. Send gain is - "automation wins" (#1654). -- Touch, latch or override modes (OQ1 options B and D, not chosen). -- Reading the instantaneous automated value. A moving value is observation (decision 17 C9), not a - control value; `effective_fader_db` keeps its static meaning. - -## Hazards - -- **One push with #1646, #1647, #1648 and #1649** (README "Must-land-together groups", amended). Before - this slice a hand fader move on an automated lane is heard as a static fader (nothing renders the - curve); after #1646-#1648 without this slice it would be `model_only`; with this slice it is an offset. - `main` must never hold the middle state. -- **Document bytes move.** Every document with automation gains the `offset` key. Each pinned digest - that moves is re-pinned with the reason "#1650 adds the automation offset". -- **The binding changes with the table.** A transaction that adds or removes a lane's automation - also changes what the row's accessor reads. #1648 D1 makes every such transaction a rebuild, - so the classifier never diffs a row whose binding changed between `current` and `next`; gate 4 - holds the removal case. -- **The `-0.0` offset.** An authored `-0.0` is a valid zero offset (D6 shows the gain bits stay); - D2's "lane not driven" rule asks for `+0.0` bits, so the canonical form has one spelling there. - -## Objective gates - -Commands: `cargo test --locked -p session -p protocol -p controls`, `cargo test --locked -p host-core ---features test-support`, `cargo test --locked -p capi`, `cargo test --locked -p conformance`, -`cargo test --locked -p host-web --features test-support --test control_parity`, -`bash scripts/check-sdk-types.sh`, `bash scripts/check-sdk-generated.sh target/ci/qualification-artifacts`, -`./target/release/realtime_policy`, `bash scripts/check-cross-targets.sh`, clippy `-D warnings`, -`cargo fmt --all -- --check`. - -1. **Grammar.** A document with offsets round-trips byte for byte through JSON and the wire; a - missing `offset` key, an unknown key in it, a non-zero `right_db` on a `left` entry and a non-zero - offset on a `pan` entry are refused at their paths with their codes. -2. **The hand move is heard** (C ABI and the browser, one test each through `control_parity.rs`'s - harness). A track with a linear fader ride takes `SET_CONTROL fader_db = +3` on its left lane: - path `live`, one revision, no plan swap; after the jump ramp the left lane equals, bit for bit, a - fresh plan of a session whose ride is the same and whose left offset is `+3`; the right lane is - unchanged. -3. **Offset 0 is no offset.** A flat ride at `-7.25` dB with offset `0` renders the bits of static - `-7.25` (#1646 gate 1's twin), with and without a reaching VCA. -4. **Re-authoring keeps it.** `SetAutomationSegments` on the ride keeps the offset (control list - value unchanged); `RemoveAutomation` removes it and the lane returns to its static value. -5. **The list.** The row shows `automated = true` and `value = +3` after gate 2; a list fetched - before the automation edit is refused `control.list.stale` after it. -6. **Realtime.** The offsets-cell write and the jump allocate and free nothing on the render thread - (`bench_support::alloc` thread counters, in `crates/control-plane/tests/`). - -## Test value - -- Gate 1 turns red if the field is optional, if the lane rule is missing, or if a non-level row - accepts an offset. -- Gate 2 turns red if the move writes the fallback (option A), rebuilds, or reaches the wrong lane. -- Gate 3 turns red if the offset term changes the gain bits of a zero offset. -- Gate 4 turns red if a curve edit drops the fan's offset, the product reason for answer C. -- Gate 5 turns red if a client cannot tell that its write is an offset. - -## Dependencies - -- #1646, #1647, #1648 and #1649 (same push). #1648 D1's `automation_row_renders` is the predicate - D3 reads. -- #1566 (batch 9): the address target and the row rule. *Keep the session JSON keys and the session - wire field IDs equal* (#1568) and *Generate the SDK's session key tables from the engine's walk* - (#1569) are open; whichever lands first, the other covers the new keys (ordering only). -- #1658 (amended) gives the trim its render half and flips D3 for trim when it adds the trim row. -- Batch R1. - -## Amendment (controls coordinator, batch 10, 2026-10-14) - -Root's ruling (README "Root's rulings (2026-10-14)"): a send's `gain_db` is a level control, so a -hand move of an automated send gain writes an offset, the same as the fader and the trim. The -owner's answer covers level controls, and the DAW trim modes include sends. - -- **D1, a shared row.** Route row 1 `gain_db` is shared (`lane: both`). Its entry holds one offset: - `left_db` and `right_db` are equal. A `SET_CONTROL` on it writes both fields with the one value. -- **D2.** `takes_offset` is true for builtins `fader_db` and `trim_db` and for route `gain_db` on a - route into a submix (the only routes whose rows are automatable). `control.automation.offset` - also refuses an entry on a shared row whose two fields differ. The domain is the route gain's, - `[-144, 24]` dB (`ROUTE_GAIN_DB_MINIMUM`, `ROUTE_GAIN_DB_MAXIMUM`), the same as the fader's. -- **D3.** The route `gain_db` row's accessor reads and writes the offset from the slice that renders - send automation (#1654), as the trim's does from #1658. Before #1654 the stored offset is - inert, as D3's last bullet says for every not-yet-rendered row. -- **D5.** For an automated send gain, the classifier's route lowering writes the route's - seven-word cell (#1654, amended), with `LiveRamps::resolve(SendGain, the edit's ramp)`. That - lowering is #1654's; this slice adds only the field, the rule and the row binding switch. -- **D7 and Non-goals.** "Send gain is 'automation wins' (#1654)" is replaced: send gain takes - an offset; on a send's matrix rows automation wins (#1654 D4). A VCA's `offset_db` is a level - 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. - -## 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. - -## Decision record: #1650a (batch 17) - -Implemented on `codex/controls-batch-17w` over `ba240b6e1`, committed as `5c8781b7e`. - -**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. - -## 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` (#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). -- *`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`). -- #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 -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. - -## 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/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md index 4b987ec68..b88f01437 100644 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md @@ -212,3 +212,130 @@ Checked against `12d4a4f9d`. that never received the repeated `SetMute`, and in (a) `gain_held` stays false. Red if a repeated `SetMute` retargets the lane or starts a mute ramp. Gate 7 holds that the change moves no bit of a session with no stored automation. + +## Amendment (controls coordinator, batch 18 spec check, 2026-10-17) + +Checked against `ad03780ee`. #1637, #1638, #1639, #1312, #1648 and #1650 have landed (batch 17, +PR #1712). + +- **Anchors.** The mute composition is `controls::effective_strip` + (`crates/controls/src/effective.rs:326-366`); `EffectiveStrip::mute` (`:198`) is + `own || vca_mute || solo_term`. The stage: `set_mute` and `mute_channel` + (`crates/builtins/src/lib.rs:3129-3155`), `apply_operation` (`:3167-3178`), `FaderOperation` + (`:4947-4958`); kernels `crates/lane/src/kernels/builtins.rs:187` and `:273`. The cells: + `FaderLane` and `FaderCells` (`crates/builtins-compiler/src/lib.rs:255-427`); the drain + `drain_fader_controls` (`:3940`). Seam: `FaderBankProcessor::seam_side` (`:3433`). Classifier: + `controls::automation_row_renders` (`crates/controls/src/automation.rs:383-387`), + `rendered_automation_changed` (`:472-489`), step 3 (`crates/host-core/src/live_delta.rs:415-418`). +- **Not landed.** The batch 13b rule has not landed: `mute_channel` still writes and retargets on + every call. It stays this slice's deliverable. +- **D1 on batch 17's code.** + - Mute cells join the plan's one program (`prepare_fader_automation`, `FaderAutomationPlan`), + after the fader cells, so a fader-only session builds the same program. A new + `controls::MUTE_CELL_RULE` sits beside `FADER_CELL_RULE`: completion `End`, + `JumpLength::Block` (the caller resolves `LiveRamps::for_row(Mute)` from the block's jump + lengths, as `block_fader_jump` does), seek `Exact`. + - The lane's slot (`LaneFaderAutomation`) gains the mute channels: cell index, `CellState`, + `terms`, ramp word, adoption. Same `a(n)` (R17-2), same span from `set_block_time` (R17-3). + - The prepared stage mute of an automated lane is `first start_value != 0 || terms` + (`prepared_fader`, `:6777`). Preparation seeds `terms` with `EffectiveStrip::mute`. + - **The terms word.** Under OQ1 = C, `terms` equals `EffectiveStrip::mute`. So the `Mute` record + the shared commit already emits (only on change, with its ramp) is the terms write, and the + cell's value word carries it unchanged. No new record, no new cell, no edit under + `crates/engine/src/realtime/`. The drain (the bank's and the scalar oracle's `drain_controls`) + gives a mute value of an automated channel to the slot: the slot stores `terms` and the ramp + word, composes `target || terms` with the cell's current target (the curve state the stage + has heard), and applies `SetMute` at the block boundary over the ramp word only when it + differs (the `read_offsets` pattern). The block's curve events then compose with the new + `terms`. + - Curve events go through the one `FaderEventDriver` (one `FaderOperationSource` per block; the + sink takes one gain and one mute operation per lane, channel and offset, gain first). A mute + cursor pushes `SetMute` over the block's mute jump length only when `curve || terms` changes. + - **Fix.** "The caller reports the mute ramp ... through #1637's `hold_until`" cannot hold: + `CellEvents` borrows the state (#1647 constraint 2) and D1.2 is deleted (B17-R11). Instead, at + each mute event at offset `o`, the driver ends that channel's fader piece at `o` and starts the + next piece at `o + 1` with the hold the `SetMute` gives (muted: held to the block's end; an + unmute over `M`: `E = o + M`), through the shipped D1.6 obligation and cut at `E`. A gain + operation at `o` keeps the old hold (gain before mute). Bounded by the block's mute events; no + buffer. + - **Seeks.** A seek `Set` composes like any event. With the 13b rule, a `Set` that does not + change the state leaves the stage as it is, so a ramp toward that state finishes (as #1647 + constraint 5 for a held fader). Root confirms this reading (report ruling 1). + - **Scalar oracle (R17-4).** Same slot and driver. A strip with mute automation and no fader + automation binds `AutomatedFaderProcessor` (`:7497`) too. + - **CanonicalFpEnv (R17-7).** The mute path has no float arithmetic (values 0 and 1, then + booleans), and preparation already runs inside the guard. No new guard. + - **For #1652.** This slice makes the program `Arc` and each strip's mute cell indices readable + by host-core preparation, so #1652's route lanes read the same table. +- **D2 on batch 17's code.** + - Row 6 joins `automation_row_renders`; `rendered_automation_changed` then makes a mute entry + change `Err(LiveRebuild::Automation)`. + - **Fix.** The fader-only readers of that predicate test the fader row: `fader_automation` and + `automated_cell` (`automation.rs:393-448`), `model_fader_offsets` (`effective.rs:488`), + `mask_fader_offsets` (`mask.rs:137`) and B17-R14's `automates` + (`crates/control-plane/src/control.rs:2613-2616`). Without it, a mute entry gives its lane a + hand offset (`S`, `fader_offset`), a fader edit becomes an offsets write, and a mute-only + rebuild is flagged `TransitionFallback`. + - **The second bullet** is replaced by the terms word above: a hand mute (OQ1 = C), a VCA mute + and solo give the lane its `Mute` record, as today; render ORs it with the curve. The commit + code does not change. + - **Carry (#1648 D2).** `continued_cells` bits 2 and 3 are the mute lanes (a new + `controls::continued_mute_lanes`, set in host-core's `StripJoin`, `prepare.rs:5338`); graph + passes the `u8` unchanged (a doc edit only). `LaneFaderAutomation::adopt` copies a mute + channel's state, `terms` and ramp by address; a changed table or a moved `a(n)` repositions; + the first block emits `SetMute` over the mute jump length only on a change. A lost mute cell: + the join emits a `Mute` record of `next`'s effective mute (as `join_level_records`, `:4660`, + releases a fader lane); the 13b rule makes it a no-op when the stage agrees. + - **TransitionFallback and watermark.** Nothing new: a mute adoption never stretches a live + move, so `automates` stays fader-only. A terms write is a live record; a mute automation edit + is a rebuild whose jump lands at the adoption block. + - **Superseded tests, rewritten in place:** `crates/host-core/tests/live_delta.rs:1411-1451` + (#1648 gate 1's three mute asserts now expect `Err(LiveRebuild::Automation)`) and the mute + branch of `a_fader_automation_edit_rebuilds_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs:2846-2857`, `ModelOnly` becomes `Rebuild`). + - Root rules whether this slice sets the control listing's `automated` flag on an automated + mute row (#1650 D4; report ruling 2). Until then the flag stays fader-only. +- **Gates.** + - Gate 1: both hosts in one binary, `hosts/host-web/tests/control_parity.rs` (batch 17's form), + at quanta 128, 100 and 37, plus a session seek into a muted span (muted exactly from the seek + sample). Widths: the same script in `crates/host-core/tests/stored_mute_automation.rs` (new) + at `Simd8` (with `avx2`) and `Simd4`, split and fused, live and between render calls, and the + scalar oracle: the same bits. + - Gate 2 runs in `crates/host-core/tests/stored_mute_automation.rs`, against the stage driven + directly with #1638's operations, with a new case: fader events inside a curve mute, and the + move at `E` after a curve unmute, at quanta 128, 100, 64 and 37, the same bits. Red if the + fader piece is not cut at the mute event. The 13b case stays in + `crates/builtins/tests/fader_ramp.rs`. + - Gate 3 runs in `control_parity.rs`, both hosts. + - Gate 4: "one terms write and no `Mute` record" now reads "one `Mute` record (the terms + write)". New cases: a mute entry on `t` gives `t` no hand offset and keeps `S`; a `SET_CONTROL + fader_db` on `t`'s unautomated fader gives a `FaderDb` record, not an offsets write. Red if a + fader-only reader sees the mute entry. + - Gate 8 (new, carry; `stored_mute_automation.rs`): a changed curve, an added entry and a removed + entry each rebuild and carry `t`'s stage; the lane jumps at the adoption block only when + `curve || terms` changes; after removal it holds the static mute; the swap block allocates and + frees nothing. A mute-only session's rebuild with a live fader move in flight completes + `Exact`. Red if a mute cell restarts, misses or repeats the adoption jump, or a lost cell keeps + the curve's state. + - Gate 6: `scripts/check-realtime-policy.sh` is deleted; run `cargo build --locked --release -p + realtime-policy && ./target/release/realtime_policy`. Add `cargo test --locked -p controls` + and `cargo test --locked -p builtins`. `crates/control-plane/Cargo.toml` already has + `bench-support` (#1649). +- **Authorized paths (this list replaces the earlier ones).** Render paths are marked (R). None is + under `crates/engine/src/realtime/`. + - (R) `crates/builtins/src/lib.rs` (the 13b rule in `mute_channel` only), + `crates/builtins/tests/fader_ramp.rs` + - (R) `crates/builtins-compiler/src/lib.rs` (mute cells, slot, seed, drain branch, driver, scalar + oracle, adopt, export for #1652) + - `crates/controls/src/{automation.rs,effective.rs,mask.rs,lib.rs}`, + `crates/controls/tests/automation.rs` + - `crates/control-plane/src/control.rs` (B17-R14's `automates` only) + - `crates/host-core/src/prepare.rs` (the strip join only), `crates/host-core/src/live_delta.rs` + (docs only), `crates/graph/src/lib.rs` (`GraphStripMove::continued_cells` doc only) + - `crates/host-core/tests/{live_delta.rs,stored_mute_automation.rs}`, + `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, + `hosts/host-web/src/tests.rs`, `crates/control-plane/tests/mute_automation_realtime.rs` (new) + - `docs/SESSION_SCHEMA_V1.md` (`:290-305`, the mute row renders), `docs/REALTIME_MEMORY.md` + (`:13`) + - Removed: `crates/host-core/src/solo.rs` (deleted), `crates/control-plane/Cargo.toml`, + `Cargo.lock` (no new dependency). diff --git a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md index 72a89b5aa..bf0ef20e7 100644 --- a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md +++ b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md @@ -215,3 +215,138 @@ Checked against `12d4a4f9d`. with an automated mute also uses this slice's seven-word cell, and its follow term ORs those VCA curves, evaluated in the tap's node time. #1655 adds it on this slice's route op; this slice needs no change for it. + +## Amendment (controls coordinator, batch 18 spec check, 2026-10-17) + +Checked against `ad03780ee`. The filed text above stays; where it disagrees, this section wins. + +**Anchors now.** +- Route cell: `RouteControlRecord` `crates/graph/src/lib.rs:963`, `ROUTE_CELL_WORDS = 6` `:1007`, + `RouteLane` `:1047`, `GraphRouteControlProducer` `:1085`. `RouteGate` `:770`, + `gated_route_coefficients` `:801`. `LatestCell` is const-generic + (`crates/engine/src/realtime/latest_cell.rs:590`), so a seven-word cell needs no edit under + `crates/engine/src/realtime/`. +- Route op: `LiveRoute` `crates/graph/src/runtime.rs:887` (`apply_pending` `:928`, `active` + `:964`, `mix` `:970`, `adopt` `:981`); the drain and activity in `execute_op` `:3676-3688`; the + mix arm `:3791`. `execute_op` already takes the block's `GraphBlockTime` (`:3660`). +- Ramp: `IndexedRamp` `crates/lane/src/kernels.rs:1589`, `coefficients_at` `:1645`, + `route_mix_ramp_block` `:1698`. The kernel takes sub-slices and a start position, so the split + needs no kernel change. `crates/lane/src/kernels.rs` leaves the paths. +- `route_values` `crates/graph-compiler/src/ids.rs:322-338` (`pub(crate)`), `route_coefficients` + `:362-376`, re-exported at `crates/graph-compiler/src/lib.rs:323`. +- Node time: `GraphBlockTime` `crates/graph/src/lib.rs:1298` (`node_span` `:1350`, + `jump_lengths` `:1341`), `GraphJumpLengths` `:1244`, `floored_input_arrival` `:1362`. + `GraphCompileReport::input_arrivals` (`crates/graph-compiler/src/lib.rs:194`) holds every node's + floored input arrival, route nodes included. A route op reads its one undelayed input before the + consumer's compensation delay, so the route node's floored input arrival is `a(tap)`. + `crates/graph-compiler/src/pdc.rs` leaves the paths. +- Route lanes attach at `crates/host-core/src/prepare.rs:2867-2888`. Since C6 (#1557) every route + into a submix is a live row, so every plan on both hosts has its lane (D1's attach is done). +- Carry: `join_routes` `prepare.rs:5473`, `resolve_route_carry` `:5537`, the join's retarget + writes `:3654`; render side `carry_live_routes` `runtime.rs:5261`. +- The one record function of a send is `route_record_between` (`prepare.rs:4154-4193`). The + classifier (`route_records`, `crates/host-core/src/live_delta.rs:1467`) and the join call it. +- Graph's dependency pin is `scripts/check-graph-policy.sh:21`; the fixture is + `scripts/test-graph-policy.sh:7-13`. Batch 17 did not add `automation` to graph (it put the + fader program in builtins-compiler), so this slice is still the first to add the edge. +- `scripts/check-realtime-policy.sh` is deleted (c1aadc234). The gate is + `cargo build --locked --release -p realtime-policy && ./target/release/realtime_policy`. +- `crates/control-plane/Cargo.toml` already has `bench-support` (`vca_automation_realtime.rs`). + It leaves the paths. + +**How the slice maps onto the landed code.** +- **Program hand-off.** The plan's `Arc` is built in builtins-compiler + (`prepare_fader_automation`, `crates/builtins-compiler/src/lib.rs:2087`; #1651 adds the mute + cells). #1651 exports, per strip, the program and the two mute cell indices to host-core + preparation, as a handle type that graph defines. Host-core passes it, with `a(tap)` from + `floored_input_arrival(report.input_arrivals, route node)`, to the route attach. Host-core needs + no `automation` dependency. The "automated" test is #1651's mute-lane predicate in `controls` + (as `automated_fader_lanes`, `crates/controls/src/automation.rs:447`). +- **Layout.** A seven-word route has its own lane type (`LatestCell<7>`) and its own boxed render + state beside the route op. Every six-word route keeps its bytes (`LIVE_ROUTE_OWNER_BYTES`, + `route_control_resources`) and its bits. The seven-word lane and its render state are charged in + `route_control_resources`; the program is charged once, by builtins-compiler. +- **Flags word.** Bit 0 route `mute`, bit 1 `follows_mute`, bits 2-3 `terms[L, R]`, bits 4-5 + `automated[L, R]`, other bits 0 (decode refuses them, as `mute > 1` today). After OQ1 = C, + `terms[lane]` is always `controls::effective`'s source-lane mute (it holds no curve), for an + automated lane and for one that is not. `automated` is the plan's: render takes it from its own + cursors, and the commit refuses, before the first write, a record whose bits differ (#1653). +- **Node time (R17-3).** The route op takes the same per-block context the builtin banks get + (`GraphBlockTime`, set once per block), calls `node_span(a(tap))` (signed, R17-5) and builds its + `automation::NodeSpan`. It uses `CellEvents` with the mute cell's rule (#1651; `SeekMode::Exact`, + so a seek gives a `Set`, length 0). +- **Ramp length.** `GraphJumpLengths` "knows no row", so graph must not spell the mute row. The + attach passes a resolver from host-core: + `LiveRamps::from_lengths(fader, mute, pan).for_row(LiveRampRow::Mute)`, the rule the strip's + stage uses. Gate 2 holds that the two lengths agree. +- **Order in one block.** First the cell at block entry: compose with the curve's value at the + block's first sample and retarget over the cell's ramp word on a bits change. Then the curve + events in offset order: each composes and retargets over the session mute length (0 for a `Set`) + on a bits change. #1651 uses the same order on the strip, so gate 2 holds. +- **Seed.** The bound target composes the curve's first `start_value` (as #1651's prepared stage + mute) with the seeded words. A route of a fresh plan whose curve at its first block's `τ_tap` + differs sets exactly there (length 0), as the strip's fresh lane does (#1647 D2, #1651). +- **Activity (fix).** Today the op decides `active` at block entry, before it mixes. A seven-word + route is active in every block where it retargets, so an unmute inside a block is mixed. It finds + its changes first (for example on a copy of its `CellState`, which is `Copy`, 32 bytes). A + muted, undelayed route with no change in the block stays inactive and moves no bit. +- **FP (R17-7).** Render composes inside the render entry's `CanonicalFpEnv`; the seed and the + commit compose inside R17-7's guards. One `const fn`, `gated_route_coefficients`, gives both. +- **Scalar oracle (R17-4).** The route op is not banked and has one form. The strip's bank and + its oracle are #1651's. +- **No queue.** D4 holds: render generates the events from the plan. + +**D3, made exact (fix).** +- The lane exists in both plans, so the move by route ID always applies. Ramp, position and mute + carry as today (`LiveRoute::adopt`). +- The follow cursors do not carry. A mute cell has no grid and no hold, so the successor places + each cursor fresh at its own `τ_tap` of the first block and seeds it with `value_at` there. +- At the successor's first block: the join's record (if any) applies first, through the cell. Then + the route compares its composed target with the adopted ramp's target. On a bits change it + retargets from `coefficients_at(position)` over the session mute length. +- A six-word successor of a seven-word predecessor (the curve removed) makes the same comparison + with its own prepared target. The model diff cannot see the curve, so only render knows the + carried state. A six-word pair keeps today's code exactly. +- The join's record for a seven-word successor route is the seven-word record from + `route_record_between` (#1653's branch). +- No `TransitionFallback` and no new watermark rule. A carried ramp whose target does not change + keeps its own length. A changed target is the new curve's own jump, which an uninterrupted plan + makes the same way at a curve event (as #1648's "Note 1" for a cell's own jump). B17-R14's + window does not read routes. + +**Gates (fixes).** +- Gate 1: drop "With live controls off in the browser too". There is no such option now (C6). + Both hosts run it at the native width: `crates/capi/src/runtime/live_tests.rs` and + `hosts/host-web/tests/control_parity.rs`. Host-core runs the same script in + `crates/host-core/tests/route_mute.rs` at `Backend::Simd8` (`cfg(target_feature = "avx2")`) and + `Backend::Simd4`, with the `Scalar` oracle (`test_only_prepare_host_runtime_*_on`). +- Gates 2 and 4 also run at `Simd8` (avx2) and `Simd4`. +- Gate 3 is a unit test in `crates/graph/src/runtime.rs`'s test module (the route op is + `pub(crate)`). Add one case: a route that unmutes at offset `o` while inactive is mixed on + `[o, q)`. +- Gate 4, new case (b): a rebuild that removes `t`'s mute entry while the curve holds the send + muted unmutes the send over the session mute length from the swap block; it allocates nothing. + Red if the six-word successor keeps the adopted mute. +- Gate 6: replace `bash scripts/check-realtime-policy.sh` with the `realtime-policy` binary above; + add `bash scripts/test-graph-policy.sh` and `bash scripts/check-builtins-policy.sh`. + +**Authorized paths after the check.** +- `crates/graph/src/lib.rs`, `crates/graph/src/runtime.rs` (route lane, cell, record, producer + write, the route op, its activity, its carry). Render code, but not under + `crates/engine/src/realtime/`: no root notice is needed. +- `crates/graph/Cargo.toml`, `Cargo.lock`, `scripts/check-graph-policy.sh`, + `scripts/test-graph-policy.sh` (each gains `automation` only). +- `crates/graph-compiler/src/ids.rs` (`route_values` public), `crates/graph-compiler/src/lib.rs` + (its re-export only). +- `crates/builtins-compiler/src/lib.rs` (only if #1651's export of the program and a strip's mute + cells needs a change; after #1651). +- `crates/host-core/src/prepare.rs` (attach, seed, resolver, join), `crates/host-core/src/lib.rs` + (re-exports only), `crates/host-core/tests/route_mute.rs`. +- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, + `hosts/host-web/src/tests.rs`, `crates/control-plane/tests/follow_automation_realtime.rs` (new). +- No path under `crates/engine/src/realtime/`. + +**For #1654 (phase 2, no scope here).** It reuses the open transform words, the flags word's spare +bits (6 and up), the per-event composition and the in-block retarget. It needs the seven-word +predicate widened to routes with automated rows, and carried cursor state by `(route, row)` for +curves that move on the grid; this slice's mute cursors are not carried. diff --git a/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md b/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md index d25c0ac2e..3cef435e9 100644 --- a/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md +++ b/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md @@ -130,3 +130,69 @@ Checked against `12d4a4f9d`. - Authorized paths: `crates/host-core/src/{live_delta.rs,route_controls.rs}`, `crates/host-core/tests/live_delta.rs`, `crates/control-plane/src/`, `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`. + +## Amendment (controls coordinator, batch 18 spec check, 2026-10-17) + +Checked against `ad03780ee`. The filed text above stays; where it disagrees, this section wins. + +**Anchors now.** +- The one record function of a send is `route_record_between`, + `crates/host-core/src/prepare.rs:4154-4193`. The classifier (`route_records`, + `crates/host-core/src/live_delta.rs:1467`) and the swap join (`join_routes`, `prepare.rs:5473`) + both call it. So the branch is in `prepare.rs`, which this slice now needs. +- The classifier lowers a route only when its rows changed or its effective gate changed + (`live_delta.rs:1019`). The gate is `controls::effective_gate` + (`crates/controls/src/effective.rs:381`). +- `route_record` and `RouteControlProducer`: `crates/host-core/src/route_controls.rs:146`, `:201`. +- The commit resolves route producers at `crates/control-plane/src/control.rs:3226-3242` and + writes them at `:3419-3421`, in the same stamp as the strip and jump-length writes. +- The in-place classifier (`classify_live_staged`, `live_delta.rs:565`) must equal the dense one + (#1613; `crates/host-core/tests/in_place_commit.rs`, R17-1). + +**How the slice maps onto the landed code.** +- **D1.** For a route that the next plan gives a seven-word cell (#1652 D1, from the `after` + model), `route_record_between` returns a seven-word record: the open transform from + `route_values` (#1652 makes it public), the flags word of #1652's amendment, and the ramp from + the existing length rule. `LiveRouteRecord` carries either a folded record or a seven-word one. + The join gets the same record with no second code. +- **Trigger (fix).** For a seven-word route, the classifier also lowers it when its source lane's + effective mute (`terms`) changes and the gate does not (for example while `follows_mute` is + off). So the cell always holds the committed words, and "writes only when a word's bits change" + is exact. +- **D2.** After OQ1 = C, `terms[lane]` is `controls::effective`'s source-lane mute, with the + overlay, for every lane (it holds no curve). Solo is on both hosts (C8). +- **D3.** The mirror and its setter are gone. `follows_mute` is an input of `controls::effective` + and the committed model is the shadow. A toggle writes the cell only. +- **D5 (the acked-batch question), made exact.** Before the first write, the commit checks that + each seven-word record goes to a seven-word producer, and that its `automated` bits equal the + producer's plan. Any mismatch is `CommandError::Internal`, before any write, so no ack precedes a + drop. +- **Watermark.** The path is `live` (`monitor` for solo), with the existing rule. The strip's + terms write (#1651) and the route's cell write of one transaction share one stamp and one block + snapshot, so they apply in the same block. No `TransitionFallback` is set. +- **FP (R17-7).** `route_values` runs inside the guards that R17-7 put at the commit entry and the + preparation entry. No new guard. + +**Gates (fixes).** +- Gate 2 runs on both hosts (solo is on the C ABI too): `hosts/host-web/tests/control_parity.rs`. +- Gate 1 also runs in host-core at `Backend::Simd8` (`cfg(target_feature = "avx2")`) and + `Backend::Simd4`, with the `Scalar` oracle: the classifier's records written through the + producers (`crates/host-core/tests/live_routes.rs`). +- Gate 3, rewritten for OQ1 = C: a `follows_mute` toggle gives one seven-word record and no folded + record; a hand mute on the automated lane gives #1651's terms write and one seven-word record; a + hand mute on the other lane gives that lane's `Mute` record and one seven-word record; a route + gain edit gives one seven-word record with the new transform; a terms change while + `follows_mute` is off gives one seven-word record; an edit that leaves every word unchanged + gives nothing. The same cases pass through the in-place path: add a session with a mute entry + and a following send to `crates/host-core/tests/support/edit_sessions.rs`. + +**Authorized paths after the check.** +- `crates/host-core/src/prepare.rs` (`route_record_between`'s branch only; after #1652), + `crates/host-core/src/live_delta.rs`, `crates/host-core/src/route_controls.rs`, + `crates/host-core/src/lib.rs` (re-exports only). +- `crates/host-core/tests/live_delta.rs`, `crates/host-core/tests/live_routes.rs`, + `crates/host-core/tests/support/edit_sessions.rs`. +- `crates/control-plane/src/control.rs` (the route write and its pre-write check). +- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, + `hosts/host-web/src/tests.rs`. +- No render code and no path under `crates/engine/src/realtime/`. diff --git a/.github/ISSUE_SPECS/1709-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 deleted file mode 100644 index 9bae588bf..000000000 --- a/.github/ISSUE_SPECS/1709-compose-live-level-values-inside-the-canonical-fp-environment.md +++ /dev/null @@ -1,168 +0,0 @@ -# Compose live level values inside the canonical floating-point environment - -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 - -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. 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 - -- 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). - - 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. - -## 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). - -## Decision record: #1709 (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 (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 - 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. - -## 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`). From d49c0c1e6e628fdd89a0870bbd32c94589e2d27d Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 07:03:14 +0000 Subject: [PATCH 02/23] Lay out the seven-word route cell for a send that follows an automated mute (#1652a) A route whose source strip has an automated mute lane gets its own lane type: the open transform, one flags word (route mute, follows_mute, terms L/R, automated L/R) and the ramp, in a LatestCell<7>, with a boxed render state beside LiveRoute so a six-word route keeps its bytes and bits. The record refuses unknown flag bits, a non-finite transform word and an over-long ramp. The route charge counts each kind on its own and the activity table once. graph gains a normal automation dependency (policy pin and fixture updated); route_values is public. No render behaviour yet. Co-Authored-By: Claude Opus 5.5 --- ...mated-mute-on-following-sends-in-render.md | 45 ++ Cargo.lock | 1 + crates/graph-compiler/src/ids.rs | 13 +- crates/graph-compiler/src/lib.rs | 2 +- crates/graph-compiler/tests/live_routes.rs | 4 +- crates/graph/Cargo.toml | 3 + crates/graph/src/lib.rs | 714 +++++++++++++++++- crates/graph/src/runtime.rs | 32 + crates/host-core/src/prepare.rs | 2 +- crates/host-core/tests/live_routes.rs | 2 +- scripts/check-graph-policy.sh | 2 +- scripts/test-graph-policy.sh | 3 +- 12 files changed, 778 insertions(+), 45 deletions(-) diff --git a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md index bf0ef20e7..0ba3066a2 100644 --- a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md +++ b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md @@ -350,3 +350,48 @@ Checked against `ad03780ee`. The filed text above stays; where it disagrees, thi bits (6 and up), the per-event composition and the in-block retarget. It needs the seven-word predicate widened to routes with automated rows, and carried cursor state by `(route, row)` for curves that move on the grid; this slice's mute cursors are not carried. + +## Decision record: #1652a (batch 18) + +Tranche R1: the seven-word cell types, the graph -> automation edge and a public `route_values`. +No render behaviour: no route op evaluation, no attach, no carry. + +- **Shipped (`crates/graph/src/lib.rs`).** `FOLLOW_ROUTE_CELL_WORDS = 7`; `RouteFollowFlags` (bit 0 + `mute`, 1 `follows_mute`, 2-3 `terms[L, R]`, 4-5 `automated[L, R]`; `from_word` refuses any bit + from 6 up) with `gate(curve)`, D2's composition + `follow_zeroed[lane] = follows_mute && ((automated[lane] && curve[lane]) || terms[lane])`; + `FollowRouteControlRecord` (open `RouteTransform`, flags, length; refuses a length over + `ROUTE_RAMP_LENGTH_MAXIMUM` and a non-finite transform word) with `to_words`/`from_words`; + `FollowRouteLane` (`LatestCell<7>` + dirty word), `FollowRouteControlLane` (render reader half, + with `read(snapshot)`), `GraphFollowRouteControlBinding`, `GraphFollowRouteControlProducer` + (`write`, `test_only_write_count`), and `follow_route_control_pair`, the one builder of a + binding/producer pair, for #1652c's attach. +- **Render state (`crates/graph/src/runtime.rs`).** `LiveFollowRoute`, its own boxed type beside + `LiveRoute` (ramp, position, mute, delayed, route index, transform, flags, control lane); + `LiveRoute` is unchanged. #1652b adds its cursors and its constructor. +- **Charge.** `route_control_resources(producers, following)` charges each kind its own cell, + owner and producer bytes and the activity table once; with `following` empty it is the old sum + term for term. New `LIVE_FOLLOW_ROUTE_OWNER_BYTES`. Measured (x86-64): six-word cell 144, + seven-word cell 168; `LIVE_ROUTE_OWNER_BYTES` 72 (unchanged), `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 96 + (grows with #1652b's cursors, charged by `size_of`); both lanes 32, both bindings 64, both + producers 72. +- **Departure.** The signature change of `route_control_resources` is a mechanical caller update + at `crates/host-core/src/prepare.rs` (one call), `crates/host-core/tests/live_routes.rs` and + `crates/graph-compiler/tests/live_routes.rs` (pass `&[]`); the last two are outside this slice's + listed paths. `RouteFollowFlags::gate` is the pure composition #1652b and #1652c both call; it + lands here with its test. +- **Edge.** `crates/graph/Cargo.toml`, `Cargo.lock`, `scripts/check-graph-policy.sh` (the pin) and + `scripts/test-graph-policy.sh` (the fixture manifest and the partial-output awk shim) gain + `automation` only. No source in graph uses it yet; #1652b's cursors do. +- **`route_values`** is `pub` and re-exported from `graph_compiler`. +- **Dead-code expects (gone by the PR boundary):** `LiveFollowRoute` (struct, "bound and read by + #1652b route op"); `FollowRouteControlLane::read` (non-test, "read by #1652b route op"); + `follow_route_control_pair` (non-test, "called by #1652c route attach"). +- **Gates.** `crates/graph/src/lib.rs` `follow_route_tests`: + `the_flags_word_round_trips_its_six_bits_and_refuses_every_other_bit`, + `the_gate_follows_the_curve_only_on_an_automated_lane`, + `the_record_round_trips_its_seven_words_and_refuses_out_of_bounds_words`, + `the_producer_write_reaches_the_binding_under_the_published_revision`, + `the_charge_counts_each_kind_its_own_bytes_and_the_activity_table_once`; all pass. The six-word + charge stays held by graph-compiler's `route_control_resources_cover_the_allocation`. The + realtime policy reports 164 marked regions (main: 160; four new). diff --git a/Cargo.lock b/Cargo.lock index 27ec5499a..d31b4413e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -891,6 +891,7 @@ dependencies = [ name = "graph" version = "0.1.0" dependencies = [ + "automation", "bench-support", "conformance", "effect-contract", diff --git a/crates/graph-compiler/src/ids.rs b/crates/graph-compiler/src/ids.rs index 02bc0104f..cbbdc0e9e 100644 --- a/crates/graph-compiler/src/ids.rs +++ b/crates/graph-compiler/src/ids.rs @@ -319,10 +319,15 @@ pub(crate) fn route_transform(gain_db: f32, matrix: [f32; 4]) -> Option Result { +/// +/// Public for a send whose source strip's mute is automated (#1652 D1): its live cell holds this +/// open transform, and render gates it with [`graph::gated_route_coefficients`] at every curve +/// event, so the transform a record carries and the one the plan lowers are one derivation. +/// +/// # Errors +/// +/// [`RouteValueError::Domain`] outside the route domain above, or for a non-finite value. +pub fn route_values(gain_db: f32, matrix: [f32; 4]) -> Result { // `contains` is false for NaN, so a non-finite value is refused here too. let in_domain = (session::ROUTE_GAIN_DB_MINIMUM..=session::ROUTE_GAIN_DB_MAXIMUM) .contains(&gain_db) diff --git a/crates/graph-compiler/src/lib.rs b/crates/graph-compiler/src/lib.rs index 1753516b2..705929f01 100644 --- a/crates/graph-compiler/src/lib.rs +++ b/crates/graph-compiler/src/lib.rs @@ -320,7 +320,7 @@ mod ids; mod pdc; mod schedule; -pub use ids::{RouteValueError, route_coefficients}; +pub use ids::{RouteValueError, route_coefficients, route_values}; #[cfg(test)] mod tests { diff --git a/crates/graph-compiler/tests/live_routes.rs b/crates/graph-compiler/tests/live_routes.rs index 9e2e2e00e..833b34494 100644 --- a/crates/graph-compiler/tests/live_routes.rs +++ b/crates/graph-compiler/tests/live_routes.rs @@ -1423,7 +1423,7 @@ fn route_control_resources_cover_the_allocation() { let model = two_into_bus(R0, Values { mute: muted, ..R1 }); let (without, _) = retained(&model, &feeds, false); let (with, producers) = retained(&model, &feeds, true); - let resources = route_control_resources(&producers); + let resources = route_control_resources(&producers, &[]); let routes = producers.len() as u64; assert_eq!(routes, 2); @@ -1507,7 +1507,7 @@ fn route_control_resources_cover_the_attached_state() { .requested_bytes .checked_sub(window.released_bytes) .expect("the attach frees nothing it did not allocate"); - let resources = route_control_resources(&producers); + let resources = route_control_resources(&producers, &[]); assert_eq!(producers.len(), 2); assert_eq!( resources.activity_bytes, 0, diff --git a/crates/graph/Cargo.toml b/crates/graph/Cargo.toml index a46612507..c2b971fdf 100644 --- a/crates/graph/Cargo.toml +++ b/crates/graph/Cargo.toml @@ -13,6 +13,9 @@ name = "graph" test-support = ["rack/test-support"] [dependencies] +# The route op of a following send evaluates its source strip's stored mute curves with the +# plan's own cursors (#1652 D2): graph cannot reach them through builtins-compiler. +automation.workspace = true engine.workspace = true effect-contract.workspace = true lane.workspace = true diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index f0b2720f6..52955f843 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1126,6 +1126,328 @@ impl GraphRouteControlProducer { } } +/// Words of a following route's cell (#1652 D1): the open transform `[gain, ll, lr, rl, rr]`'s +/// bits, the flags word ([`RouteFollowFlags`]) and the ramp length. A route into a submix whose +/// source strip has an automated mute lane holds this cell instead of the six folded words, so +/// render can gate the transform itself at every curve event. +pub(crate) const FOLLOW_ROUTE_CELL_WORDS: usize = 7; + +/// The flags word of a following route's cell (#1652 D1, batch 18 layout): bit 0 the route's own +/// `mute`, bit 1 `follows_mute`, bits 2 and 3 `terms[L, R]`, bits 4 and 5 `automated[L, R]`. Every +/// other bit is 0; [`Self::from_word`] refuses a word with any of them set. +/// +/// `terms[lane]` is the source lane's effective mute without its curve (`controls::effective`'s +/// source-lane mute: static mute, VCA mute and solo term). `automated[lane]` is fixed by the plan: +/// whether that source lane has a stored mute curve. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RouteFollowFlags { + /// The route's own mute: it silences the route whatever the source does. + pub mute: bool, + /// Whether the route follows its source strip's mute. + pub follows_mute: bool, + /// Per source lane `[L, R]`: the lane's mute terms other than its curve. + pub terms: [bool; 2], + /// Per source lane `[L, R]`: whether the plan automates the lane's mute. + pub automated: [bool; 2], +} + +impl RouteFollowFlags { + const MUTE: u32 = 1 << 0; + const FOLLOWS_MUTE: u32 = 1 << 1; + const TERMS: [u32; 2] = [1 << 2, 1 << 3]; + const AUTOMATED: [u32; 2] = [1 << 4, 1 << 5]; + /// Every bit the layout defines; any other bit set makes a word no record wrote. + const DEFINED: u32 = (1 << 6) - 1; + + /// The flags word. + #[must_use] + pub const fn to_word(self) -> u32 { + const fn bit(set: bool, bit: u32) -> u32 { + if set { bit } else { 0 } + } + bit(self.mute, Self::MUTE) + | bit(self.follows_mute, Self::FOLLOWS_MUTE) + | bit(self.terms[0], Self::TERMS[0]) + | bit(self.terms[1], Self::TERMS[1]) + | bit(self.automated[0], Self::AUTOMATED[0]) + | bit(self.automated[1], Self::AUTOMATED[1]) + } + + // REALTIME_POLICY_BEGIN + /// The flags a word holds, or `None` when a bit above bit 5 is set. + #[must_use] + pub const fn from_word(word: u32) -> Option { + if word & !Self::DEFINED != 0 { + return None; + } + Some(Self { + mute: word & Self::MUTE != 0, + follows_mute: word & Self::FOLLOWS_MUTE != 0, + terms: [word & Self::TERMS[0] != 0, word & Self::TERMS[1] != 0], + automated: [ + word & Self::AUTOMATED[0] != 0, + word & Self::AUTOMATED[1] != 0, + ], + }) + } + + /// The route's gate while the source lanes' curves are at `curve` (#1652 D2): + /// `follow_zeroed[lane] = follows_mute && ((automated[lane] && curve[lane]) || terms[lane])`. A + /// lane the plan does not automate ignores its `curve` entry. Pure; [`gated_route_coefficients`] + /// of the open transform with this gate is the route's target. + #[must_use] + pub const fn gate(self, curve: [bool; 2]) -> RouteGate { + let [left, right] = [ + self.follows_mute && ((self.automated[0] && curve[0]) || self.terms[0]), + self.follows_mute && ((self.automated[1] && curve[1]) || self.terms[1]), + ]; + RouteGate { + mute: self.mute, + follow_zeroed: [left, right], + } + } + // REALTIME_POLICY_END +} + +/// One live change of a following route (#1652 D1): its open transform, its flags and the ramp in +/// samples, `0` being a step. Render composes the target itself +/// ([`RouteFollowFlags::gate`], [`gated_route_coefficients`]), so the record holds no folded +/// coefficients. The constructor is the only way to build one, so every record a lane drains is in +/// bounds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FollowRouteControlRecord { + transform: RouteTransform, + flags: RouteFollowFlags, + length: u32, +} + +impl FollowRouteControlRecord { + // REALTIME_POLICY_BEGIN + /// `None` when `length > ROUTE_RAMP_LENGTH_MAXIMUM` or when a transform word is not finite: the + /// gate folds the transform at every curve event, so it must give finite coefficients. + #[must_use] + pub fn new(transform: RouteTransform, flags: RouteFollowFlags, length: u32) -> Option { + let RouteTransform { + gain, + ll, + lr, + rl, + rr, + } = transform; + if length > ROUTE_RAMP_LENGTH_MAXIMUM + || ![gain, ll, lr, rl, rr].iter().all(|word| word.is_finite()) + { + return None; + } + Some(Self { + transform, + flags, + length, + }) + } + /// The open transform: the route's linear gain and its 2x2, ungated. + #[must_use] + pub const fn transform(&self) -> RouteTransform { + self.transform + } + /// The flags word's fields. + #[must_use] + pub const fn flags(&self) -> RouteFollowFlags { + self.flags + } + /// The ramp's length in samples; `0` is a step. + #[must_use] + pub const fn length(&self) -> u32 { + self.length + } + // REALTIME_POLICY_END + + /// The cell words `[gain, ll, lr, rl, rr, flags, length]`. + pub(crate) fn to_words(self) -> [u32; FOLLOW_ROUTE_CELL_WORDS] { + let RouteTransform { + gain, + ll, + lr, + rl, + rr, + } = self.transform; + [ + gain.to_bits(), + ll.to_bits(), + lr.to_bits(), + rl.to_bits(), + rr.to_bits(), + self.flags.to_word(), + self.length, + ] + } + + // REALTIME_POLICY_BEGIN + /// The record a cell's words hold: `None` for words no record wrote (a flags bit above bit 5, + /// a non-finite transform word or a ramp over the bound), which render skips. The words come + /// from [`Self::to_words`], so the checks are the constructor's and the flags word's own. + pub(crate) fn from_words(words: [u32; FOLLOW_ROUTE_CELL_WORDS]) -> Option { + let [gain, ll, lr, rl, rr, flags, length] = words; + Self::new( + RouteTransform { + gain: f32::from_bits(gain), + ll: f32::from_bits(ll), + lr: f32::from_bits(lr), + rl: f32::from_bits(rl), + rr: f32::from_bits(rr), + }, + RouteFollowFlags::from_word(flags)?, + length, + ) + } + // REALTIME_POLICY_END +} + +/// What one following route shares between its writer (control) and its reader (render): one +/// seven-word latest-target cell and its dirty word (#1652 D1). Its own type, so a six-word +/// route's [`RouteLane`] keeps its bytes. +pub(crate) struct FollowRouteLane { + pub(crate) cell: engine::realtime::LatestCell, + pub(crate) dirty: engine::realtime::DirtyWord, +} + +/// The render-side half of one following route's cell (#1652 D1): the shared cell, the reader's +/// last applied sequence, the dirty reader and the session's `live_values_superseded` count. The +/// counterpart of [`RouteControlLane`] for the seven-word cell. +pub struct FollowRouteControlLane { + pub(crate) lane: std::sync::Arc, + pub(crate) reader: engine::realtime::CellReader, + pub(crate) dirty: engine::realtime::DirtyReader, + pub(crate) superseded: engine::realtime::LiveValuesSuperseded, +} + +// REALTIME_POLICY_BEGIN +impl FollowRouteControlLane { + /// Read the cell under the block's snapshot: the newest record whose revision is `<= + /// snapshot` and that this reader has not applied, or `None`. An idle cell costs one + /// `Relaxed` load. When it applies sequence `s` after `p`, the `s - p - 1` writes it replaced + /// join the session's `live_values_superseded` (#1312 D2). Allocation-free and lock-free. + #[cfg_attr(not(test), expect(dead_code, reason = "read by #1652b route op"))] + pub(crate) fn read(&mut self, snapshot: LiveSnapshot) -> Option { + if self.dirty.pending(&self.lane.dirty) & 1 == 0 { + return None; + } + let engine::realtime::CellRead::Applied { words, .. } = self.reader.read_counted( + &self.lane.cell, + snapshot, + &mut self.dirty, + 0, + &self.superseded, + ) else { + return None; + }; + // Only `write` fills the cell, from a validated record, so the words decode. + let record = FollowRouteControlRecord::from_words(words); + debug_assert!( + record.is_some(), + "a following route cell held words no record wrote" + ); + record + } +} +// REALTIME_POLICY_END + +/// One following route's lane, carried beside the prepared routes as +/// [`GraphRouteControlBinding`] is (#1652 D1). +pub struct GraphFollowRouteControlBinding { + /// The route node this lane drives. + pub node: GraphNodeId, + /// Reader half of the seven-word cell; the writer stays on the control plane. + pub control: Box, +} + +/// The control-plane half of one following route's seven-word cell (#1652 D1): the counterpart of +/// [`GraphRouteControlProducer`]. +pub struct GraphFollowRouteControlProducer { + /// The route's session ID. + pub route_id: Box, + lane: std::sync::Arc, + writer: engine::realtime::CellWriter, + /// As [`GraphRouteControlProducer`]'s: the route-activity bytes this attach added, the same + /// for every producer of one attach. + activity_bytes: u64, + /// The largest single allocation of that table, or `0` with it. + activity_largest: u64, +} + +impl GraphFollowRouteControlProducer { + /// Write `record` as the route's newest value, stamped with the revision of the transaction + /// that commits it. A write cannot fail and never waits; render applies the newest and counts + /// each replaced one. Validation is [`FollowRouteControlRecord::new`]'s, made before any + /// write. The caller publishes the stamp's revision on the plan's gate after every write of + /// the transaction (#1312 D7). + pub fn write( + &mut self, + stamp: engine::realtime::LiveStamp<'_>, + record: FollowRouteControlRecord, + ) { + self.writer.write( + &self.lane.cell, + &self.lane.dirty, + 0, + stamp, + record.to_words(), + ); + } +} + +#[cfg(any(test, feature = "test-support"))] +impl GraphFollowRouteControlProducer { + /// Test-only: how many writes the cell has taken. Valid while no write is in flight, which a + /// quiescent test thread guarantees. + #[doc(hidden)] + #[must_use] + pub fn test_only_write_count(&self) -> u64 { + engine::realtime::CellReader::new() + .peek_unread(&self.lane.cell) + .map_or(0, |(_, sequence)| sequence) + } +} + +/// One following route's binding and producer over one fresh seven-word cell (#1652 D1). +/// `activity_bytes` and `activity_largest` are the attach's route-activity charge, as for a +/// six-word producer. +#[cfg_attr(not(test), expect(dead_code, reason = "called by #1652c route attach"))] +pub(crate) fn follow_route_control_pair( + route_id: &StableGraphId, + superseded: &engine::realtime::LiveValuesSuperseded, + activity_bytes: u64, + activity_largest: u64, +) -> ( + GraphFollowRouteControlBinding, + GraphFollowRouteControlProducer, +) { + let lane = std::sync::Arc::new(FollowRouteLane { + cell: engine::realtime::LatestCell::new(), + dirty: engine::realtime::DirtyWord::new(), + }); + ( + GraphFollowRouteControlBinding { + node: GraphNodeId::Route { + route_id: route_id.clone(), + }, + control: Box::new(FollowRouteControlLane { + lane: std::sync::Arc::clone(&lane), + reader: engine::realtime::CellReader::new(), + dirty: engine::realtime::DirtyReader::new(), + superseded: superseded.clone(), + }), + }, + GraphFollowRouteControlProducer { + route_id: route_id.as_str().into(), + lane, + writer: engine::realtime::CellWriter::new(), + activity_bytes, + activity_largest, + }, + ) +} + /// Why [`PreparedGraphPlan::attach_route_controls`] refused. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum GraphRouteControlError { @@ -1140,6 +1462,10 @@ pub enum GraphRouteControlError { /// D8): stated so a caller can check [`route_control_resources`] from first principles. pub const LIVE_ROUTE_OWNER_BYTES: usize = runtime::live_route_owner_bytes(); +/// The bytes bind allocates for one following route's render-side owner, its boxed state (#1652 +/// D1): stated so a caller can check [`route_control_resources`] from first principles. +pub const LIVE_FOLLOW_ROUTE_OWNER_BYTES: usize = runtime::live_follow_route_owner_bytes(); + /// What attaching route controls and binding the plan add (issue #1220 D8): an upper bound on /// what the attached artifact retains before bind and on what the bound plan retains. /// @@ -1153,12 +1479,15 @@ pub struct RouteControlResources { pub routes: u64, /// Each route's one shared allocation, the `Arc` of its latest-target cell and dirty word /// (`SharedAllocation`: the cell's three slots, the dirty word and the owner - /// header). Exact (#1347 D1). + /// header; `SharedAllocation` for a following route). Exact (#1347 D1, + /// #1652 D1). pub cell_bytes: u64, /// Per route: the boxed [`RouteControlLane`], the plan's [`GraphRouteControlBinding`] entry - /// (held until bind) and the boxed render-side owner bind builds. + /// (held until bind) and the boxed render-side owner bind builds; for a following route the + /// boxed [`FollowRouteControlLane`], its [`GraphFollowRouteControlBinding`] and its owner. pub owner_bytes: u64, - /// The producer table: one [`GraphRouteControlProducer`] per route. + /// The producer tables: one [`GraphRouteControlProducer`] per six-word route and one + /// [`GraphFollowRouteControlProducer`] per following route. pub producer_table_bytes: u64, /// The route IDs' bytes, twice: the producer's `route_id`, and the plan's /// [`GraphRouteControlBinding`] node ID (held until bind) -- a second heap copy (#1220 verdict @@ -1171,51 +1500,99 @@ pub struct RouteControlResources { pub largest_allocation_bytes: u64, } -/// The resources of one [`PreparedGraphPlan::attach_route_controls`] call's `producers` and of the -/// bind that consumes its lanes (issue #1220 D8): an upper bound on both the attached and the bound -/// state ([`RouteControlResources`]). Saturating; empty for no producers. +/// The resources of one [`PreparedGraphPlan::attach_route_controls`] call's `producers` and +/// `following` producers, and of the bind that consumes their lanes (issue #1220 D8, #1652 D1): an +/// upper bound on both the attached and the bound state ([`RouteControlResources`]). A six-word +/// route is charged its [`RouteLane`] cell, its owner ([`LIVE_ROUTE_OWNER_BYTES`]) and its +/// producer; a following route its own seven-word cell, owner ([`LIVE_FOLLOW_ROUTE_OWNER_BYTES`]) +/// and producer, so the charge of a plan with no following route is what it was. The +/// route-activity table is one per plan, charged once. Saturating; empty for no producers. #[must_use] -pub fn route_control_resources(producers: &[GraphRouteControlProducer]) -> RouteControlResources { +pub fn route_control_resources( + producers: &[GraphRouteControlProducer], + following: &[GraphFollowRouteControlProducer], +) -> RouteControlResources { let count = |value: usize| u64::try_from(value).unwrap_or(u64::MAX); - if producers.is_empty() { + if producers.is_empty() && following.is_empty() { return RouteControlResources::default(); } - let routes = count(producers.len()); - // Every route's shared allocation is the same type, so the same bytes. + let six = count(producers.len()); + let seven = count(following.len()); + let routes = six.saturating_add(seven); + // Every route's shared allocation of one kind is the same type, so the same bytes. let cell = count(core::mem::size_of::< engine::realtime::SharedAllocation, >()); - let cell_bytes = routes.saturating_mul(cell); + let follow_cell = count(core::mem::size_of::< + engine::realtime::SharedAllocation, + >()); + let cell_bytes = six + .saturating_mul(cell) + .saturating_add(seven.saturating_mul(follow_cell)); let mut route_id_bytes = 0_u64; - let mut largest = cell; - for producer in producers { + let mut largest = 0_u64; + if six > 0 { + largest = largest.max(cell); + } + if seven > 0 { + largest = largest.max(follow_cell); + } + let route_ids = producers + .iter() + .map(|producer| &producer.route_id) + .chain(following.iter().map(|producer| &producer.route_id)); + for route_id in route_ids { // The producer's copy and the binding's node-ID copy. - route_id_bytes = - route_id_bytes.saturating_add(count(producer.route_id.len()).saturating_mul(2)); - largest = largest.max(count(producer.route_id.len())); + route_id_bytes = route_id_bytes.saturating_add(count(route_id.len()).saturating_mul(2)); + largest = largest.max(count(route_id.len())); } let lane = count(core::mem::size_of::()); let binding = count(core::mem::size_of::()); let owner = count(LIVE_ROUTE_OWNER_BYTES); - let owner_bytes = routes.saturating_mul(lane.saturating_add(binding).saturating_add(owner)); - let producer_table_bytes = - routes.saturating_mul(count(core::mem::size_of::())); - let activity_bytes = producers - .iter() - .map(|producer| producer.activity_bytes) - .max() - .unwrap_or(0); - let activity_largest = producers + let follow_lane = count(core::mem::size_of::()); + let follow_binding = count(core::mem::size_of::()); + let follow_owner = count(LIVE_FOLLOW_ROUTE_OWNER_BYTES); + let owner_bytes = six + .saturating_mul(lane.saturating_add(binding).saturating_add(owner)) + .saturating_add( + seven.saturating_mul( + follow_lane + .saturating_add(follow_binding) + .saturating_add(follow_owner), + ), + ); + let six_producers = + six.saturating_mul(count(core::mem::size_of::())); + let seven_producers = seven.saturating_mul(count(core::mem::size_of::< + GraphFollowRouteControlProducer, + >())); + let producer_table_bytes = six_producers.saturating_add(seven_producers); + let activity = producers .iter() - .map(|producer| producer.activity_largest) - .max() - .unwrap_or(0); - largest = largest - .max(lane) - .max(owner) - .max(routes.saturating_mul(binding)) - .max(producer_table_bytes) - .max(activity_largest); + .map(|producer| (producer.activity_bytes, producer.activity_largest)) + .chain( + following + .iter() + .map(|producer| (producer.activity_bytes, producer.activity_largest)), + ); + let (activity_bytes, activity_largest) = activity.fold((0, 0), |(bytes, largest), next| { + (bytes.max(next.0), largest.max(next.1)) + }); + if six > 0 { + largest = largest + .max(lane) + .max(owner) + .max(six.saturating_mul(binding)) + .max(six_producers); + } + if seven > 0 { + largest = largest + .max(follow_lane) + .max(follow_owner) + .max(seven.saturating_mul(follow_binding)) + .max(seven_producers); + } + largest = largest.max(activity_largest); RouteControlResources { routes, cell_bytes, @@ -9820,3 +10197,272 @@ mod tests { assert_eq!(after[0].destination, output); } } + +#[cfg(test)] +mod follow_route_tests { + //! #1652a: the seven-word cell of a following route: its flags word, its record, the producer + //! write and the charge. + use super::*; + use engine::realtime::{LiveSnapshot, LiveValuesSuperseded, RevisionGate}; + + fn flags_of(word: u32) -> RouteFollowFlags { + RouteFollowFlags { + mute: word & 1 != 0, + follows_mute: word & 2 != 0, + terms: [word & 4 != 0, word & 8 != 0], + automated: [word & 16 != 0, word & 32 != 0], + } + } + + fn transform() -> RouteTransform { + RouteTransform { + gain: 0.5, + ll: 1.0, + lr: -0.0, + rl: f32::from_bits(1), + rr: -0.75, + } + } + + fn route_id(value: &str) -> StableGraphId { + StableGraphId::parse(value).expect("a route ID") + } + + /// The layout: bit 0 mute, 1 follows_mute, 2-3 terms L/R, 4-5 automated L/R. Every one of the + /// 64 defined words decodes to its fields and encodes back to itself; a word with any bit + /// from 6 up set is refused. + /// + /// Test value: red if two fields swap bits, if a field is dropped on the round trip, or if a + /// word with an undefined bit decodes (render would apply words no record wrote). + #[test] + fn the_flags_word_round_trips_its_six_bits_and_refuses_every_other_bit() { + for word in 0..64_u32 { + let flags = RouteFollowFlags::from_word(word).expect("a defined word"); + assert_eq!(flags, flags_of(word), "word {word:#b}"); + assert_eq!(flags.to_word(), word); + for bit in 6..32 { + assert_eq!( + RouteFollowFlags::from_word(word | 1 << bit), + None, + "bit {bit} set" + ); + } + } + assert_eq!(RouteFollowFlags::from_word(u32::MAX), None); + } + + /// D2's composition, over every flags word and every curve state: + /// `follow_zeroed[lane] = follows_mute && ((automated[lane] && curve[lane]) || terms[lane])`, + /// and the route's own mute passes through. + /// + /// Test value: red if a curve value reaches a lane the plan does not automate, if a lane reads + /// the other lane's curve or terms, or if the follow ignores `follows_mute`. + #[test] + fn the_gate_follows_the_curve_only_on_an_automated_lane() { + for word in 0..64_u32 { + let flags = flags_of(word); + for curve in [[false, false], [true, false], [false, true], [true, true]] { + let gate = flags.gate(curve); + assert_eq!(gate.mute, flags.mute); + for lane in 0..2 { + let expected = flags.follows_mute + && ((flags.automated[lane] && curve[lane]) || flags.terms[lane]); + assert_eq!( + gate.follow_zeroed[lane], expected, + "word {word:#b}, curve {curve:?}, lane {lane}" + ); + } + } + } + } + + /// The record keeps the open transform's bits exactly (negative zero and a subnormal + /// coefficient included), its flags and its length through the seven cell words, and refuses a + /// ramp over the bound, a non-finite transform word and a flags word with an undefined bit. + /// + /// Test value: red if a word moves in the layout, if a transform word is rounded or + /// canonicalised on the way, or if words no record wrote decode. + #[test] + fn the_record_round_trips_its_seven_words_and_refuses_out_of_bounds_words() { + let flags = flags_of(0b10_0110); + let record = FollowRouteControlRecord::new(transform(), flags, 480).expect("a record"); + let words = record.to_words(); + assert_eq!( + words, + [ + 0.5_f32.to_bits(), + 1.0_f32.to_bits(), + (-0.0_f32).to_bits(), + 1, + (-0.75_f32).to_bits(), + 0b10_0110, + 480, + ] + ); + assert_eq!(FollowRouteControlRecord::from_words(words), Some(record)); + assert_eq!(record.transform(), transform()); + assert_eq!(record.flags(), flags); + assert_eq!(record.length(), 480); + + let bound = FollowRouteControlRecord::new(transform(), flags, ROUTE_RAMP_LENGTH_MAXIMUM) + .expect("the bound is in bounds"); + assert_eq!( + FollowRouteControlRecord::from_words(bound.to_words()), + Some(bound) + ); + assert_eq!( + FollowRouteControlRecord::new(transform(), flags, ROUTE_RAMP_LENGTH_MAXIMUM + 1), + None + ); + for word in 0..5 { + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let mut words = words; + words[word] = bad.to_bits(); + assert_eq!(FollowRouteControlRecord::from_words(words), None); + } + } + let mut long = words; + long[6] = ROUTE_RAMP_LENGTH_MAXIMUM + 1; + assert_eq!(FollowRouteControlRecord::from_words(long), None); + let mut undefined = words; + undefined[5] |= 1 << 6; + assert_eq!(FollowRouteControlRecord::from_words(undefined), None); + } + + /// The producer writes the seven words into the route's own cell, and render's half reads + /// them under the block's snapshot: nothing before the revision is published, the newest + /// record once it is, each replaced write counted as superseded, nothing on an idle cell. + /// + /// Test value: red if the producer writes another cell than the binding reads, if a value is + /// readable before its revision is published, or if a replaced write is not counted. + #[test] + fn the_producer_write_reaches_the_binding_under_the_published_revision() { + let superseded = LiveValuesSuperseded::new(); + let (mut binding, mut producer) = + follow_route_control_pair(&route_id("send"), &superseded, 0, 0); + assert_eq!(&*producer.route_id, "send"); + assert_eq!( + binding.node, + GraphNodeId::Route { + route_id: route_id("send") + } + ); + let (gate, mut pin) = RevisionGate::pair(0); + let first = + FollowRouteControlRecord::new(transform(), flags_of(0b01_0010), 64).expect("first"); + let second = + FollowRouteControlRecord::new(transform(), flags_of(0b01_0011), 0).expect("second"); + + assert_eq!(binding.control.read(gate.snapshot()), None, "an idle cell"); + producer.write(pin.stamp(1).expect("stamp"), first); + assert_eq!( + binding.control.read(gate.snapshot()), + None, + "revision 1 is not published" + ); + pin.publish(&gate, 1); + producer.write(pin.stamp(2).expect("stamp"), second); + pin.publish(&gate, 2); + assert_eq!(producer.test_only_write_count(), 2); + assert_eq!(binding.control.read(gate.snapshot()), Some(second)); + assert_eq!(superseded.get(), 1, "the first write was replaced unread"); + assert_eq!( + binding.control.read(LiveSnapshot::ALL), + None, + "applied once" + ); + } + + fn six_word_producer(id: &str, activity: (u64, u64)) -> GraphRouteControlProducer { + GraphRouteControlProducer { + route_id: id.into(), + lane: std::sync::Arc::new(RouteLane::new()), + writer: engine::realtime::CellWriter::new(), + activity_bytes: activity.0, + activity_largest: activity.1, + } + } + + /// The charge of six-word and following routes together is each kind's own cell, owner and + /// producer bytes, the route IDs twice, and the plan's one route-activity table once. A plan + /// with no following route is charged exactly as before (the six-word terms alone). + /// + /// Test value: red if a following route is charged a six-word cell or owner, if a kind is left + /// out, or if the activity table is charged per kind. + #[test] + fn the_charge_counts_each_kind_its_own_bytes_and_the_activity_table_once() { + let size = |bytes: usize| bytes as u64; + let superseded = LiveValuesSuperseded::new(); + let activity = (4_096, 1_024); + let six = [ + six_word_producer("a", activity), + six_word_producer("bb", activity), + ]; + let seven: Vec<_> = ["ccc", "dddd", "eeeee"] + .iter() + .map(|id| { + follow_route_control_pair(&route_id(id), &superseded, activity.0, activity.1).1 + }) + .collect(); + let six_cell = size(core::mem::size_of::< + engine::realtime::SharedAllocation, + >()); + let seven_cell = size(core::mem::size_of::< + engine::realtime::SharedAllocation, + >()); + assert!(seven_cell > six_cell, "the seventh word has its own cell"); + let six_owner = size( + core::mem::size_of::() + + core::mem::size_of::() + + LIVE_ROUTE_OWNER_BYTES, + ); + let seven_owner = size( + core::mem::size_of::() + + core::mem::size_of::() + + LIVE_FOLLOW_ROUTE_OWNER_BYTES, + ); + let six_table = 2 * size(core::mem::size_of::()); + let seven_table = 3 * size(core::mem::size_of::()); + let ids = 2 * (1 + 2 + 3 + 4 + 5); + + let both = route_control_resources(&six, &seven); + assert_eq!(both.routes, 5); + assert_eq!(both.cell_bytes, 2 * six_cell + 3 * seven_cell); + assert_eq!(both.owner_bytes, 2 * six_owner + 3 * seven_owner); + assert_eq!(both.producer_table_bytes, six_table + seven_table); + assert_eq!(both.route_id_bytes, ids); + assert_eq!(both.activity_bytes, activity.0); + assert_eq!( + both.total_bytes, + 2 * six_cell + + 3 * seven_cell + + 2 * six_owner + + 3 * seven_owner + + six_table + + seven_table + + ids + + activity.0 + ); + assert!(both.largest_allocation_bytes >= seven_cell.max(activity.1)); + + let alone = route_control_resources(&six, &[]); + assert_eq!(alone.routes, 2); + assert_eq!(alone.cell_bytes, 2 * six_cell); + assert_eq!(alone.owner_bytes, 2 * six_owner); + assert_eq!(alone.producer_table_bytes, six_table); + assert_eq!(alone.route_id_bytes, 2 * (1 + 2)); + assert_eq!(alone.activity_bytes, activity.0); + let following = route_control_resources(&[], &seven); + assert_eq!(following.cell_bytes, 3 * seven_cell); + assert_eq!(following.owner_bytes, 3 * seven_owner); + assert_eq!( + alone.total_bytes + following.total_bytes - activity.0, + both.total_bytes, + "the activity table is charged once" + ); + assert_eq!( + route_control_resources(&[], &[]), + RouteControlResources::default() + ); + } +} diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index 2829ef0c9..d33cb0873 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -987,6 +987,38 @@ impl LiveRoute { // REALTIME_POLICY_END +/// One following route's render state (#1652 D1): a route into a submix whose source strip has an +/// automated mute lane. Its own type, boxed beside [`LiveRoute`], so a six-word route keeps its +/// bytes ([`live_route_owner_bytes`]) and its bits. +/// +/// The ramp fields mean what [`LiveRoute`]'s do. `transform` and `flags` are the last applied +/// cell words (or the prepared ones): render gates the open transform itself +/// ([`crate::RouteFollowFlags::gate`]) at every curve event of the source's mute. +#[expect(dead_code, reason = "bound and read by #1652b route op")] +pub(crate) struct LiveFollowRoute { + /// The current ramp; `ramp.coefficients_at(position)` are the last mixed frame's coefficients. + ramp: IndexedRamp, + /// The ramp index of the last frame mixed, saturated at `ramp.length`; never wraps. + position: u32, + /// Whether the current target silences the route. + mute: bool, + /// Whether the route's one consumer input carries a compensation delay. + delayed: bool, + /// The route's index in the plan's [`RouteActivity`]. + route: u32, + /// The open transform the last applied record (or preparation) gave. + transform: crate::RouteTransform, + /// The flags the last applied record (or preparation) gave. + flags: crate::RouteFollowFlags, + control: Box, +} + +/// The bytes bind allocates for one following route's render-side owner (#1652 D1): its box. +#[must_use] +pub(crate) const fn live_follow_route_owner_bytes() -> usize { + core::mem::size_of::() +} + /// Integer-sample plugin-delay compensation for one stereo edge. /// /// The line is `samples` words per channel and the block is exchanged with it in at most two diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index d0488f723..3ae76444b 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -2880,7 +2880,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( } else { Vec::new() }; - let route_control_resources = graph::route_control_resources(&graph_route_controls); + let route_control_resources = graph::route_control_resources(&graph_route_controls, &[]); // The producers in canonical route-ID order; a carried send's retarget is written through them. let mut route_controls: Vec = graph_route_controls .into_iter() diff --git a/crates/host-core/tests/live_routes.rs b/crates/host-core/tests/live_routes.rs index 54d88e004..a28871ee6 100644 --- a/crates/host-core/tests/live_routes.rs +++ b/crates/host-core/tests/live_routes.rs @@ -982,7 +982,7 @@ fn graph_route_resources(document: &str) -> RouteControlResources { let producers = artifact .attach_route_live_controls(&host_core::LiveValuesSuperseded::default()) .expect("attach"); - graph::route_control_resources(&producers) + graph::route_control_resources(&producers, &[]) } /// What host-core admits against `maximum_graph_session_plus_plan_bytes` for one preparation. diff --git a/scripts/check-graph-policy.sh b/scripts/check-graph-policy.sh index 8fe165b40..06da6c8eb 100644 --- a/scripts/check-graph-policy.sh +++ b/scripts/check-graph-policy.sh @@ -18,7 +18,7 @@ compiler_manifest="crates/graph-compiler/Cargo.toml" graph_dependencies_raw="$(gate_toml_dependencies "$graph_manifest" graph)" || exit $? production_graph_dependencies="$graph_dependencies_raw" -[[ "$production_graph_dependencies" == $'effect-contract.workspace\nengine.workspace\nlane.workspace\nrack.workspace' ]] || +[[ "$production_graph_dependencies" == $'automation.workspace\neffect-contract.workspace\nengine.workspace\nlane.workspace\nrack.workspace' ]] || fail 'render graph dependency boundary changed' gate_scan_required 'control-plane compiler SHA-256 dependency' '^sha2[.]workspace = true$' '' "$compiler_manifest" >/dev/null || exit $? diff --git a/scripts/test-graph-policy.sh b/scripts/test-graph-policy.sh index 5e517d48b..2781fc836 100755 --- a/scripts/test-graph-policy.sh +++ b/scripts/test-graph-policy.sh @@ -7,6 +7,7 @@ mkdir -p "$base/crates/graph/src" "$base/crates/graph-compiler/src" "$base/crate cat >"$base/crates/graph/Cargo.toml" <<'EOF' [dependencies] rack.workspace = true +automation.workspace = true lane.workspace = true engine.workspace = true effect-contract.workspace = true @@ -43,7 +44,7 @@ reset; make_shim "$scratch/find-partial" find '/usr/bin/find "$@"; printf "plaus reset; make_shim "$scratch/find-workspace" find 'case "$*" in "crates -name"*) [[ -z ${SHIM_PARTIAL:-} ]] || { /usr/bin/find "$@"; printf "crates/other/src/lib.rs\n"; }; exit 7;; esac; exec /usr/bin/find "$@"'; for partial in '' 1; do fail 'workspace Rust discovery traversal errored (find status 7)' env SHIM_PARTIAL="$partial" PATH="$scratch/find-workspace:$PATH" bash "$checker" "$work"; done reset; make_shim "$scratch/sed-error" sed '[[ -z ${SHIM_PARTIAL:-} ]] || printf "impl PreparedPlanExecutor for Graph {}\n"; exit 8'; for partial in '' 1; do fail 'source read failed' env SHIM_PARTIAL="$partial" PATH="$scratch/sed-error:$PATH" bash "$checker" "$work"; done reset; make_shim "$scratch/sort-error" sort '[[ -z ${SHIM_PARTIAL:-} ]] || /usr/bin/sort "$@"; exit 9'; for partial in '' 1; do fail 'dependency extraction failed for crates/graph/Cargo.toml (sort status 9)' env SHIM_PARTIAL="$partial" PATH="$scratch/sort-error:$PATH" bash "$checker" "$work"; done -reset; make_shim "$scratch/awk-error" awk '[[ -z ${SHIM_PARTIAL:-} ]] || printf "effect-contract.workspace\nengine.workspace\nlane.workspace\nrack.workspace\n"; exit 7'; for partial in '' 1; do fail 'dependency extraction failed for crates/graph/Cargo.toml (awk status 7)' env SHIM_PARTIAL="$partial" PATH="$scratch/awk-error:$PATH" bash "$checker" "$work"; done +reset; make_shim "$scratch/awk-error" awk '[[ -z ${SHIM_PARTIAL:-} ]] || printf "automation.workspace\neffect-contract.workspace\nengine.workspace\nlane.workspace\nrack.workspace\n"; exit 7'; for partial in '' 1; do fail 'dependency extraction failed for crates/graph/Cargo.toml (awk status 7)' env SHIM_PARTIAL="$partial" PATH="$scratch/awk-error:$PATH" bash "$checker" "$work"; done reset; make_shim "$scratch/rg-publication" rg 'case "$*" in *PlanPublisher*) [[ -z ${SHIM_PARTIAL:-} ]] || printf "1:std::fs\n"; exit 7;; esac; exec /usr/bin/rg "$@"'; for partial in '' 1; do fail 'graph publication predicate scan errored (rg exit 7)' env SHIM_PARTIAL="$partial" PATH="$scratch/rg-publication:$PATH" bash "$checker" "$work"; done reset; make_shim "$scratch/rg-executor" rg 'case "$*" in *"impl PreparedPlanExecutor for"*) [[ -z ${SHIM_PARTIAL:-} ]] || printf "1:impl PreparedPlanExecutor for Graph\n"; exit 7;; esac; exec /usr/bin/rg "$@"'; for partial in '' 1; do fail 'prepared-plan executor predicate' env SHIM_PARTIAL="$partial" PATH="$scratch/rg-executor:$PATH" bash "$checker" "$work"; done printf 'graph policy fixtures: ok\n' From 0e078aad89b60c27886249a9eb8f94426e3b6294 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 07:14:52 +0000 Subject: [PATCH 03/23] Leave the fader stage as it was on a SetMute that changes nothing (#1651 T1) mute_channel returns at once when the requested mute equals the channel's current mute state, so a repeated SetMute (an operation or the live set_mute) no longer restarts a fader ramp in flight or starts a mute ramp that holds the fader events. Gate a_repeated_mute_leaves_the_stage_as_it_was (Simd4, Simd8, scalar; quanta 128 and 100) is red on the old code. Four tests that ended an unmute ramp with a repeated unmute are rewritten to the new rule (the three batch 17 hold-release gates now free the lane with a live fader write). audit capi pcm_digest unchanged (bffc21e857b5e134). Co-Authored-By: Claude Opus 5.5 --- .../1651-render-stored-mute-automation.md | 30 ++++ crates/builtins/src/lib.rs | 8 ++ crates/builtins/tests/fader_ramp.rs | 128 ++++++++++++++++++ .../tests/stored_fader_automation.rs | 55 +++++++- 4 files changed, 215 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md index b88f01437..f042ec902 100644 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md @@ -339,3 +339,33 @@ PR #1712). (`:13`) - Removed: `crates/host-core/src/solo.rs` (deleted), `crates/control-plane/Cargo.toml`, `Cargo.lock` (no new dependency). + +## Decision record: T1 (batch 18) + +- **Shipped.** The batch 13b rule: `FaderRampStage::mute_channel` (`crates/builtins/src/lib.rs`) + returns at once when `muted` equals the channel's current mute state. No retarget, and + `mute_ramp` and the ramp's remaining frames stay as they were. One compare on a `bool` array + element; no allocation, no new panic path. Every route reaches it: `apply_operation`'s `SetMute` + and the live `set_mute`, on the Simd8 and Simd4 banks and the scalar track. +- **Gate (13b Gate 2, new case).** `crates/builtins/tests/fader_ramp.rs`, + `a_repeated_mute_leaves_the_stage_as_it_was`, on every subject (Simd4, Simd8 with `avx2`, + scalar), at quanta 128 and 100: (a) an unmuted lane with `RetargetGain` in flight takes + `SetMute { false, 240 }` at offset 20 and a `RetargetGain` at offset 30; (b) a lane in a 300-sample + mute ramp takes `SetMute { true, 240 }` at offset 20; (c) the live `set_mute` repeats the state + between blocks. Each block, both planes, is bit-identical to a twin without the repeat; the held + flag agrees, and in (a) `gain_held` stays false. Red on the base `mute_channel` (case (a), Simd4, + q=128, block 0: the repeat restarts the fader ramp over 240 samples); green with the change. +- **Superseded tests, rewritten in place** (each used a repeated zero-window unmute to end an + unmute ramp, the behaviour this rule removes): + - `crates/builtins/tests/fader_ramp.rs::the_held_flag_follows_the_mute_ramp`: mutes the lane + first, then unmutes over 40, so the unmute is a real change. + - `crates/host-core/tests/stored_fader_automation.rs`: + `a_live_write_that_frees_a_held_lane_releases_its_hold`, + `a_live_write_that_frees_a_lane_with_nothing_owed_releases_its_hold` and + `an_offsets_jump_at_the_entry_that_frees_the_lane_keeps_its_ramp` (ramping form) free the lane + with a live `FaderDb` write (`live_fader`, `Act::fader`) instead. The R17-10 release depends on + the stage's held state at the block entry, not on the record kind, so the gates keep their + claims. This edit is outside the spec's authorized list (a test file of batch 17's slice); it is + mechanical to the new rule. +- **Measured.** `audit capi` `pcm_digest` `bffc21e857b5e134` (base: the same), `allocations` 0. +- **Departures.** None from the rule's text. diff --git a/crates/builtins/src/lib.rs b/crates/builtins/src/lib.rs index e8d73531a..013951955 100644 --- a/crates/builtins/src/lib.rs +++ b/crates/builtins/src/lib.rs @@ -3141,7 +3141,15 @@ impl FaderRampStage { } /// One channel of [`Self::set_mute`]. A nonzero window starts a mute ramp (D2). + /// + /// A call whose `muted` equals the channel's current mute state changes nothing (issue #1651, + /// batch 13b ruling): no retarget, and the mute ramp flag and the ramp's remaining frames stay + /// as they were. So a repeated mute never restarts a fader ramp in flight or a mute ramp, and + /// never holds the gain for a window. fn mute_channel(&mut self, lane: usize, channel: usize, muted: bool, smoothing_samples: u32) { + if self.muted[channel][lane] == muted { + return; + } self.muted[channel][lane] = muted; let target = if muted { 0.0 diff --git a/crates/builtins/tests/fader_ramp.rs b/crates/builtins/tests/fader_ramp.rs index 10496afff..274cec22d 100644 --- a/crates/builtins/tests/fader_ramp.rs +++ b/crates/builtins/tests/fader_ramp.rs @@ -976,6 +976,129 @@ fn at_one_offset_the_gain_applies_before_the_mute() { } } +/// Gate 2, batch 13b case: a `SetMute` whose `muted` equals the lane's mute state leaves the stage +/// as it was -- no retarget, no mute ramp, the ramp in flight unchanged. Each block is bit for bit a +/// twin's that never received the repeated `SetMute`. +/// +/// (a) An unmuted lane with a fader `RetargetGain` in flight takes `SetMute { false, 240 }` inside +/// the ramp, then a `RetargetGain` 10 samples later: red if the repeat restarts the fader ramp over +/// 240 samples or sets the mute ramp, which would hold the later gain move and the held flag. +/// (b) A lane in a mute ramp toward `0.0` takes `SetMute { true, 240 }` inside it: red if the repeat +/// restarts the ramp over 240 samples. (c) The live setter repeats on the same lanes, between blocks. +#[test] +fn a_repeated_mute_leaves_the_stage_as_it_was() { + let ride = ( + 0, + 0, + FaderOperation::RetargetGain { + gain: 0.3, + samples: 300, + }, + ); + let after = ( + 30, + 0, + FaderOperation::RetargetGain { + gain: 0.8, + samples: 100, + }, + ); + let repeat_unmute = ( + 20, + 0, + FaderOperation::SetMute { + muted: false, + samples: 240, + }, + ); + let repeat_mute = ( + 20, + 0, + FaderOperation::SetMute { + muted: true, + samples: 240, + }, + ); + for (name, build) in Subject::subjects() { + for (case, mute_first, with, without) in [ + ( + "a: unmuted, fader ramp in flight", + false, + vec![ride, repeat_unmute, after], + vec![ride, after], + ), + ( + "b: mute ramp in flight", + true, + vec![repeat_mute], + Vec::::new(), + ), + ] { + for quantum in [128_usize, 100] { + let (mut subject, mut twin) = (build(), build()); + if mute_first { + for lane in [&mut subject, &mut twin] { + lane.live_mute(0, true, 300); + } + } + let width = subject.width(); + for block in 0..4 { + let start = (block * quantum) as u64; + let (mut left, mut right) = signal(quantum, width, block); + let (mut twin_left, mut twin_right) = (left.clone(), right.clone()); + let (ops, twin_ops): (&[Timed], &[Timed]) = if block == 0 { + (&with, &without) + } else { + (&[], &[]) + }; + subject.process_with(&mut left, &mut right, start, ops); + twin.process_with(&mut twin_left, &mut twin_right, start, twin_ops); + let context = format!("{name} {case} q={quantum} block={block}"); + assert_eq!(bits(&left), bits(&twin_left), "left: {context}"); + assert_eq!(bits(&right), bits(&twin_right), "right: {context}"); + assert_eq!( + subject.held(0), + twin.held(0), + "the held flag agrees: {context}" + ); + if !mute_first && block == 0 { + assert!(!subject.held(0), "gain_held stays false: {context}"); + } + } + } + } + + // (c) The live setter repeating the lane's state, in flight, between blocks. + for (case, mute_first) in [("unmuted", false), ("muting", true)] { + let (mut subject, mut twin) = (build(), build()); + for lane in [&mut subject, &mut twin] { + if mute_first { + lane.live_mute(0, true, 300); + } else { + lane.live_gain(0, -14.0, 300); + } + } + let width = subject.width(); + for block in 0..4 { + if block == 1 { + subject.live_mute(0, mute_first, 240); + if !mute_first { + assert!(!subject.held(0), "{name} {case}: gain_held stays false"); + } + } + let start = (block * 128) as u64; + let (mut left, mut right) = signal(128, width, block); + let (mut twin_left, mut twin_right) = (left.clone(), right.clone()); + subject.process(&mut left, &mut right, start); + twin.process(&mut twin_left, &mut twin_right, start); + let context = format!("{name} live {case} block={block}"); + assert_eq!(bits(&left), bits(&twin_left), "left: {context}"); + assert_eq!(bits(&right), bits(&twin_right), "right: {context}"); + } + } + } +} + /// D2: the held flag follows the mute ramp. It is set by a windowed mute change, cleared the /// frame the countdown ends, cleared by a live retarget during an unmute ramp, and carried /// across a plan swap's lane export and import. @@ -990,6 +1113,9 @@ fn the_held_flag_follows_the_mute_ramp() { let mut right = left.clone(); bank.process(&mut left, &mut right, frames); }; + // A mute that repeats the lane's state starts no ramp (batch 13b), so mute it first. + bank.set_mute(0, BuiltinLaneSelector::Left, true, 0) + .expect("lane"); bank.set_mute(0, BuiltinLaneSelector::Left, false, 40) .expect("lane"); assert_eq!( @@ -1022,6 +1148,8 @@ fn the_held_flag_follows_the_mute_ramp() { "cleared as the countdown ends" ); + bank.set_mute(0, BuiltinLaneSelector::Left, true, 0) + .expect("lane"); bank.set_mute(0, BuiltinLaneSelector::Left, false, 40) .expect("lane"); bank.set_fader_db(0, BuiltinLaneSelector::Left, -6.0, 8) diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index 5dec27c79..c368443b3 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -1872,6 +1872,27 @@ fn live_mute(rig: &mut Rig, lanes: builtins::BuiltinLaneSelector, muted: bool, w }); } +/// Write a live fader record on track 0's `lanes` (`db` over `window`), applied at the next block +/// entry. On a lane in an unmute ramp it ends the ramp: a repeated unmute does not, because a mute +/// that repeats the lane's state leaves the stage as it was (#1651, batch 13b). +fn live_fader(rig: &mut Rig, lanes: builtins::BuiltinLaneSelector, db: f32, 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::FaderDb { + lanes, + db, + 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] { @@ -2016,8 +2037,9 @@ fn fade_from(from: f32, end: u64) -> Vec { /// 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 +/// fade from `-30` to `0` dB; a live unmute of 4,000 samples is drained at 0, and a live fader write +/// of `-10` dB over window 0 at 3,200 ends it there (a second unmute would not: a mute that repeats +/// the lane's state changes nothing, #1651 batch 13b). 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. /// @@ -2030,7 +2052,7 @@ fn a_live_write_that_frees_a_held_lane_releases_its_hold() { 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), + 3_200 => live_fader(rig, builtins::BuiltinLaneSelector::Both, -10.0, 0), _ => {} }); for plane in &out { @@ -2098,6 +2120,7 @@ fn form_rig( struct Act { at: u64, mute: Option<(builtins::BuiltinLaneSelector, bool, u32)>, + fader: Option<(builtins::BuiltinLaneSelector, f32, u32)>, offsets: Option<(f64, u32)>, jump: Option, } @@ -2108,6 +2131,7 @@ const fn act(at: u64) -> Act { Act { at, mute: None, + fader: None, offsets: None, jump: None, } @@ -2153,6 +2177,16 @@ fn acted( }, ); } + if let Some((lanes, db, window)) = act.fader { + handles.strip_controls[0].fader.write( + stamp, + builtins_compiler::TrackFaderRecord::FaderDb { + lanes, + db, + smoothing_samples: window, + }, + ); + } if let Some((offsets_sum, smoothing_samples)) = act.offsets { for channel in 0..2 { handles.strip_controls[0].fader.write_offsets( @@ -2203,7 +2237,8 @@ fn never_muted(model: &SessionModel) -> SessionModel { /// 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 +/// fader write of `-10` dB over window 0 at 999 frees the lane (a repeated unmute would change +/// nothing, #1651 batch 13b). 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. @@ -2238,7 +2273,7 @@ fn a_live_write_that_frees_a_lane_with_nothing_owed_releases_its_hold() { ..act(962) }, Act { - mute: Some((lanes, false, 0)), + fader: Some((lanes, -10.0, 0)), ..act(999) }, ], @@ -2265,7 +2300,8 @@ fn a_live_write_that_frees_a_lane_with_nothing_owed_releases_its_hold() { /// 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 +/// 4,000 samples is in progress from 0, and a live fader write of `-30` dB frees the lane in place +/// of the repeated unmute). 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 @@ -2301,6 +2337,13 @@ fn an_offsets_jump_at_the_entry_that_frees_the_lane_keeps_its_ramp() { for ramping in [false, true] { let mut acts = vec![freeing]; if ramping { + // A repeated unmute would leave the 4,000-sample ramp running (#1651 batch 13b), + // so a live fader write at the curve's `-30` dB frees the lane. + acts[0] = Act { + mute: None, + fader: Some((Both, -30.0, 0)), + ..freeing + }; acts.insert( 0, Act { From 062be53861d148a23d3d84ebd02d181e9fa88af4 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 07:19:23 +0000 Subject: [PATCH 04/23] Follow an automated source mute in the route op at the tap's arrival (#1652b) A following route reads its seven-word cell at block entry, then walks its source lanes' mute cells in the tap's node time (node_span at a(tap)), composes the gate with RouteFollowFlags::gate and gated_route_coefficients, and retargets only when the coefficients' bits change: a new indexed ramp from coefficients_at(position) at the event's offset over the session mute length (exact on a seek). A route inactive at block entry that opens inside the block mixes the rest. Graph takes the mute length rule from host-core and never spells the row. Six-word routes keep their code, bytes and bits. Gate 3 (split at o in {1,63,64,127} at q=128 and in a block of 100) and five focused tests, each red under a named mutation. Realtime policy 164 -> 165. Co-Authored-By: Claude Opus 5.5 --- ...mated-mute-on-following-sends-in-render.md | 62 ++ crates/graph/src/lib.rs | 195 +++- crates/graph/src/runtime.rs | 858 +++++++++++++++++- 3 files changed, 1088 insertions(+), 27 deletions(-) diff --git a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md index 0ba3066a2..c26082938 100644 --- a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md +++ b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md @@ -395,3 +395,65 @@ No render behaviour: no route op evaluation, no attach, no carry. `the_charge_counts_each_kind_its_own_bytes_and_the_activity_table_once`; all pass. The six-word charge stays held by graph-compiler's `route_control_resources_cover_the_allocation`. The realtime policy reports 164 marked regions (main: 160; four new). + +## Decision record: #1652b (batch 18) + +Tranche R2: the following route op in render. All in `crates/graph`; no attach, no carry. + +- **Shipped (`crates/graph/src/lib.rs`).** `GraphMuteJumpLength = fn(GraphJumpLengths) -> u32`, the + mute row's rule host-core hands in, so graph never spells the row. `GraphFollowRouteSource` (the + plan's `Arc`, `cells[L, R]`, `a(tap)`, the jump rule, the seeded flags); + `new` refuses no automated lane, a cell outside the program and `flags.automated` that disagree + with `cells`. `GraphFollowRouteControlBinding` gains `source`, and `follow_route_control_pair` + takes it. A plan field `follow_route_controls` (empty until #1652c's attach) goes to bind. +- **Render (`crates/graph/src/runtime.rs`).** `LiveFollowRoute::new` seeds each automated lane's + `CellState` with its curve's first `start_value` and settles the ramp on + `gated_route_coefficients(transform, flags.gate(curve))`, with the plan's `automated`. + `NodeKind::LiveFollowRoute`. `node_kind` takes a following lane before a six-word lane; both + planning-metadata forms count it as a live route (it never folds); bind gives it its activity + index and `delayed` flag. `begin_block`: read the cell; compose with the curve the route has + heard; retarget over the record's ramp only on a bits change; take the block's spans from + `node_span(a(tap))` (a fresh route's first block lands a `Set` at 0 where the curve differs from + the seed, as the strip's fresh lane); then decide activity. `mix`: walk both lanes' + `CellEvents` (`SeekMode::Exact`) in offset order, compose at each offset, and retarget only on a + bits change: mix `[from, o)`, `IndexedRamp::new(coefficients_at(position), target, J or 0)`, + continue. The six-word `LiveRoute` and its arm are unchanged. +- **Activity (exact).** Active when the route is not muted-settled-undelayed at entry. Otherwise + a dry walk over copies of the cursors (`CellState` is `Copy`) finds out whether some change leaves + it not muted-settled. An inactive block commits the copies. No change in such a block retargets, + because every silencing target is `[+0.0; 4]`. A route that unmutes at `o` mixes the whole + block; `[0, o)` mixes the silent coefficients, which can contribute only a signed zero. +- **Choices.** (1) Events of both lanes at one offset compose once; the length is 0 when one of + them is a `Set` (a seek is exact). (2) The `mute` flag follows every composition; only the + coefficients' bits decide a retarget. (3) The jump length is clamped to + `ROUTE_RAMP_LENGTH_MAXIMUM`. (4) With no timeline, no jump-length cell, or a span at the `i64` + edge, the block has no events and the curve holds (the fader driver's rule). (5) A held grid + event changes no curve. +- **R18-4 (root).** No `TransitionFallback` for a route edit. A route retargets only on its own + curve's jump or on its own cell's record, so no carried live move is stretched or cut by a + rebuild, and B17-R14's window reads no route. +- **Owner bytes (x86-64).** `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 96 -> 296 (cursors, spans, program + `Arc`, arrival, rule); `GraphFollowRouteControlBinding` 64 -> 112 (`GraphFollowRouteSource` 48). + Both are charged by `size_of`. `LIVE_ROUTE_OWNER_BYTES` stays 72. +- **Gates (`crates/graph/src/runtime.rs` `follow_route_op_tests`, all pass).** Gate 3: + `a_retarget_inside_the_block_equals_the_block_split_at_its_offset` (o in {1, 63, 64, 127} at + q = 128, {1, 63, 64, 99} at q = 100, against a kernel-level split reference and against the op + run as two blocks); `a_route_inactive_at_block_entry_that_unmutes_inside_the_block_mixes_the_rest`; + `the_event_lands_where_the_tap_carries_the_curve_sample` (`a(tap) = 64`); + `an_event_that_changes_no_target_bit_retargets_nothing`; + `a_seek_sets_the_target_exactly_where_it_reaches_the_tap`; + `the_bound_target_composes_the_curves_first_values`. In `lib.rs` `follow_route_tests`: + `the_source_refuses_no_lane_a_missing_cell_and_disagreeing_flags`. Red checks (temporary + mutations, reverted): retarget from the target or from the ramp's start -> gate 3 red; events + timed at arrival 0 -> the tap case red; activity decided at entry -> the unmute case red; always + active -> the same case red; no bits guard -> the guard case red; a seek ramping over `J` -> the + seek case red. None depends on a bank width (the route op mixes at `FrameLane`). +- **Realtime policy.** 165 marked regions (164 before; one new: the cursors, the walk, the block + entry and the mix). `follow_route_control_pair` keeps its dead-code expect until #1652c. +- **For #1652c (attach).** Pass `GraphFollowRouteSource::new(program, cells, a(tap), rule, + flags)`: the program and the two mute cell indices from #1651's export; `a(tap) = + floored_input_arrival(report.input_arrivals, route node)`; rule = a non-capturing + `|l| LiveRamps::from_lengths(l.fader_samples, l.mute_samples, l.pan_samples).for_row(LiveRampRow::Mute)`; + the seeded flags (route mute, `follows_mute`, `terms` from `controls::effective`, `automated == + cells`). Fill `follow_route_controls`, leave those routes out of `route_controls`, and charge + them. The carry (`live_route_rows`, `carry_live_routes`, D3) does not list following routes yet. diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 52955f843..fb0b24d1a 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -894,6 +894,9 @@ pub struct PreparedGraphPlan { /// [`attach_route_controls`](Self::attach_route_controls) runs. `None` and an empty list both /// bind every route op to its prepared constants, exactly as a plan without the field did. route_controls: Option>, + /// #1652b: one seven-word lane per route into a submix whose source strip has an automated + /// mute lane. Empty until #1652c's attach fills it, so every route binds as it did before. + follow_route_controls: Vec, banks: Vec, builtin_banks: Vec, observers: Vec, @@ -1327,7 +1330,6 @@ impl FollowRouteControlLane { /// snapshot` and that this reader has not applied, or `None`. An idle cell costs one /// `Relaxed` load. When it applies sequence `s` after `p`, the `s - p - 1` writes it replaced /// join the session's `live_values_superseded` (#1312 D2). Allocation-free and lock-free. - #[cfg_attr(not(test), expect(dead_code, reason = "read by #1652b route op"))] pub(crate) fn read(&mut self, snapshot: LiveSnapshot) -> Option { if self.dirty.pending(&self.lane.dirty) & 1 == 0 { return None; @@ -1352,13 +1354,83 @@ impl FollowRouteControlLane { } // REALTIME_POLICY_END +/// The length, in samples, of one jump of a stored mute curve, resolved from the block's three +/// session lengths (#1652b). Host-core hands in the row rule (`controls::LiveRamps::from_lengths` +/// and `for_row(LiveRampRow::Mute)`, the rule the strip's mute stage uses), so graph never spells +/// the mute row and the strip ramp and the send ramp cannot differ in length. +pub type GraphMuteJumpLength = fn(GraphJumpLengths) -> u32; + +/// What one following route reads of its source strip's stored mute (#1652 D2, #1652b): the plan's +/// immutable automation program, the source lanes' mute cells in it, the arrival `a(tap)` of the +/// route's tap, the mute row's jump length and the seeded flags word. +/// +/// The program is the one the strip's own stage reads (an `Arc` clone, no copy); the route keeps +/// its own cursors over it, so two readers of one table never share state. `a(tap)` is the route +/// node's floored input arrival (`floored_input_arrival` of the compile report's +/// `input_arrivals`): a route op reads its one undelayed input before its consumer's compensation +/// delay. Only [`Self::new`] builds one, so every cell it names is in its program. +#[derive(Clone)] +pub struct GraphFollowRouteSource { + pub(crate) program: std::sync::Arc, + pub(crate) cells: [Option; 2], + pub(crate) arrival: u64, + pub(crate) mute_jump: GraphMuteJumpLength, + pub(crate) flags: RouteFollowFlags, +} + +impl GraphFollowRouteSource { + /// The source of a following route: `cells[lane]` is the source lane's mute cell in + /// `program`, `None` for a lane the plan does not automate; `arrival` is `a(tap)`; + /// `mute_jump` resolves the mute row's jump length; `flags` is the seeded flags word. + /// + /// `None` when no lane is automated (the route keeps the six-word cell), when a cell is not in + /// `program`, or when `flags.automated` disagrees with `cells` (the plan fixes which lanes + /// are automated, D1). + #[must_use] + pub fn new( + program: std::sync::Arc, + cells: [Option; 2], + arrival: u64, + mute_jump: GraphMuteJumpLength, + flags: RouteFollowFlags, + ) -> Option { + let automated = cells.map(|cell| cell.is_some()); + let in_program = cells + .iter() + .flatten() + .all(|&cell| program.cell(cell as usize).is_some()); + (automated != [false; 2] && in_program && flags.automated == automated).then_some(Self { + program, + cells, + arrival, + mute_jump, + flags, + }) + } + + /// `a(tap)`, the arrival the route's events are timed at. + #[must_use] + pub const fn arrival(&self) -> u64 { + self.arrival + } + + /// The seeded flags word. + #[must_use] + pub const fn flags(&self) -> RouteFollowFlags { + self.flags + } +} + /// One following route's lane, carried beside the prepared routes as -/// [`GraphRouteControlBinding`] is (#1652 D1). +/// [`GraphRouteControlBinding`] is (#1652 D1), with what its route op reads of the source strip's +/// stored mute (#1652b). pub struct GraphFollowRouteControlBinding { /// The route node this lane drives. pub node: GraphNodeId, /// Reader half of the seven-word cell; the writer stays on the control plane. pub control: Box, + /// The source strip's mute cells, `a(tap)`, the jump length and the seeded flags. + pub source: GraphFollowRouteSource, } /// The control-plane half of one following route's seven-word cell (#1652 D1): the counterpart of @@ -1409,13 +1481,14 @@ impl GraphFollowRouteControlProducer { } } -/// One following route's binding and producer over one fresh seven-word cell (#1652 D1). -/// `activity_bytes` and `activity_largest` are the attach's route-activity charge, as for a +/// One following route's binding and producer over one fresh seven-word cell (#1652 D1), reading +/// `source` (#1652b). `activity_bytes` and `activity_largest` are the attach's route-activity charge, as for a /// six-word producer. #[cfg_attr(not(test), expect(dead_code, reason = "called by #1652c route attach"))] pub(crate) fn follow_route_control_pair( route_id: &StableGraphId, superseded: &engine::realtime::LiveValuesSuperseded, + source: GraphFollowRouteSource, activity_bytes: u64, activity_largest: u64, ) -> ( @@ -1437,6 +1510,7 @@ pub(crate) fn follow_route_control_pair( dirty: engine::realtime::DirtyReader::new(), superseded: superseded.clone(), }), + source, }, GraphFollowRouteControlProducer { route_id: route_id.as_str().into(), @@ -2769,6 +2843,7 @@ impl PreparedGraphPlan { effect_controls: parts.effect_controls, effect_observations: parts.effect_observations, route_controls: None, + follow_route_controls: Vec::new(), banks: parts.banks, builtin_banks: parts.builtin_banks, observers: parts.observers, @@ -5214,7 +5289,8 @@ impl GraphExecutor { plan.track_delays, frames, ) - .with_route_controls(plan.route_controls.unwrap_or_default()); + .with_route_controls(plan.route_controls.unwrap_or_default()) + .with_follow_route_controls(plan.follow_route_controls); // Issue #918: the claims a bank's gather may read in place, in claim order. Only a driver // that lends its played planes offers any, and `build_sequential` decides which of them // are bound in place. @@ -10228,6 +10304,100 @@ mod follow_route_tests { StableGraphId::parse(value).expect("a route ID") } + /// The mute row's jump length of the tests: the session's `mute_ms` length. + pub(crate) fn mute_length(lengths: GraphJumpLengths) -> u32 { + lengths.mute_samples + } + + /// A program of one mute-shaped cell per given lane (steps, completion `End`, block jump + /// length), and the source over it at `arrival`, following, with no other term. + pub(crate) fn mute_source( + lanes: [Option<&[automation::Segment]>; 2], + arrival: u64, + ) -> GraphFollowRouteSource { + let descriptions: Vec<_> = lanes + .iter() + .flatten() + .map(|segments| automation::CellDescription { + segments, + grid_ramp: 64, + grid: 64, + jump: automation::JumpLength::Block, + completion: automation::Completion::End, + no_restart: false, + }) + .collect(); + let program = + std::sync::Arc::new(automation::ProgramBuilder::build(&descriptions).expect("cells")); + let mut next = 0; + let cells = lanes.map(|lane| { + lane.map(|_| { + next += 1; + next - 1 + }) + }); + let flags = RouteFollowFlags { + follows_mute: true, + automated: cells.map(|cell| cell.is_some()), + ..RouteFollowFlags::default() + }; + GraphFollowRouteSource::new(program, cells, arrival, mute_length, flags) + .expect("a valid source") + } + + /// A step curve: `values[i]` from `times[i]` on (the first value from the start). + pub(crate) fn steps(times: &[u64], values: &[f32], end: u64) -> Vec { + times + .iter() + .zip(values) + .enumerate() + .map(|(index, (&start, &value))| automation::Segment { + start, + end: times.get(index + 1).copied().unwrap_or(end), + start_value: value, + end_value: values.get(index + 1).copied().unwrap_or(value), + shape: automation::Shape::Step, + }) + .collect() + } + + /// A source refuses no automated lane, a cell outside its program and `automated` flags that + /// disagree with its cells. + /// + /// Test value: red if a route with no automated lane takes the seven-word render state, or if + /// render could look up a cell its program does not hold. + #[test] + fn the_source_refuses_no_lane_a_missing_cell_and_disagreeing_flags() { + let curve = steps(&[0, 128], &[0.0, 1.0], 4_096); + let source = mute_source([Some(&curve), None], 7); + assert_eq!(source.arrival(), 7); + let program = std::sync::Arc::clone(&source.program); + let flags = source.flags(); + let new = |cells, flags| { + GraphFollowRouteSource::new( + std::sync::Arc::clone(&program), + cells, + 0, + mute_length, + flags, + ) + .is_some() + }; + assert!(new([Some(0), None], flags)); + assert!(!new([None, None], RouteFollowFlags::default()), "no lane"); + assert!(!new([Some(1), None], flags), "a cell outside the program"); + assert!( + !new( + [Some(0), None], + RouteFollowFlags { + automated: [true, true], + ..flags + } + ), + "flags that disagree" + ); + } + /// The layout: bit 0 mute, 1 follows_mute, 2-3 terms L/R, 4-5 automated L/R. Every one of the /// 64 defined words decodes to its fields and encodes back to itself; a word with any bit /// from 6 up set is refused. @@ -10339,7 +10509,7 @@ mod follow_route_tests { fn the_producer_write_reaches_the_binding_under_the_published_revision() { let superseded = LiveValuesSuperseded::new(); let (mut binding, mut producer) = - follow_route_control_pair(&route_id("send"), &superseded, 0, 0); + follow_route_control_pair(&route_id("send"), &superseded, one_lane_source(), 0, 0); assert_eq!(&*producer.route_id, "send"); assert_eq!( binding.node, @@ -10373,6 +10543,10 @@ mod follow_route_tests { ); } + fn one_lane_source() -> GraphFollowRouteSource { + mute_source([Some(&steps(&[0], &[0.0], 64)), None], 0) + } + fn six_word_producer(id: &str, activity: (u64, u64)) -> GraphRouteControlProducer { GraphRouteControlProducer { route_id: id.into(), @@ -10401,7 +10575,14 @@ mod follow_route_tests { let seven: Vec<_> = ["ccc", "dddd", "eeeee"] .iter() .map(|id| { - follow_route_control_pair(&route_id(id), &superseded, activity.0, activity.1).1 + follow_route_control_pair( + &route_id(id), + &superseded, + one_lane_source(), + activity.0, + activity.1, + ) + .1 }) .collect(); let six_cell = size(core::mem::size_of::< diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index d33cb0873..4103023d0 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -987,14 +987,17 @@ impl LiveRoute { // REALTIME_POLICY_END -/// One following route's render state (#1652 D1): a route into a submix whose source strip has an -/// automated mute lane. Its own type, boxed beside [`LiveRoute`], so a six-word route keeps its -/// bytes ([`live_route_owner_bytes`]) and its bits. +/// One following route's render state (#1652 D1, D2): a route into a submix whose source strip has +/// an automated mute lane. Its own type, boxed beside [`LiveRoute`], so a six-word route keeps its +/// bytes ([`live_route_owner_bytes`]), its code path and its bits. /// /// The ramp fields mean what [`LiveRoute`]'s do. `transform` and `flags` are the last applied -/// cell words (or the prepared ones): render gates the open transform itself -/// ([`crate::RouteFollowFlags::gate`]) at every curve event of the source's mute. -#[expect(dead_code, reason = "bound and read by #1652b route op")] +/// cell words (or the seeded ones): render gates the open transform itself +/// ([`crate::RouteFollowFlags::gate`], [`gated_route_coefficients`]) at the block entry after its +/// cell changes and at every event of the source lanes' stored mute curves, which it evaluates +/// with its own cursors ([`FollowCursors`]) in the node time of its tap, +/// `τ_tap(r) = timeline(r + ΣP - a(tap))`. Only a target whose bits change retargets: a new +/// [`IndexedRamp`] from the coefficients the route is at. pub(crate) struct LiveFollowRoute { /// The current ramp; `ramp.coefficients_at(position)` are the last mixed frame's coefficients. ramp: IndexedRamp, @@ -1008,11 +1011,366 @@ pub(crate) struct LiveFollowRoute { route: u32, /// The open transform the last applied record (or preparation) gave. transform: crate::RouteTransform, - /// The flags the last applied record (or preparation) gave. + /// The flags the last applied record (or preparation) gave; render composes with the plan's + /// `automated` ([`FollowCursors::automated`]), never the record's. flags: crate::RouteFollowFlags, control: Box, + /// The cursors over the source lanes' stored mute cells. + follow: FollowCursors, + /// This block's node spans and jump length, set by [`Self::begin_block`] and read by + /// [`Self::mix`]. + block: FollowBlock, } +/// A following route's own cursors over its source strip's stored mute cells (#1652 D2): the +/// plan's immutable program, a cell and a [`automation::CellState`] per source lane, the curve +/// state the route has heard, `a(tap)` and the mute row's jump-length rule. +struct FollowCursors { + program: std::sync::Arc, + /// `[L, R]`; `None` for a lane the plan does not automate. + cells: [Option; 2], + states: [automation::CellState; 2], + /// Per lane, the curve value the route has heard (`value != 0`), from the seed on. + curve: [bool; 2], + /// `a(tap)`. + arrival: u64, + mute_jump: crate::GraphMuteJumpLength, + /// The route has rendered no block yet: its first block sets the curve exactly at its first + /// sample when the curve there differs from the seed (#1647 D2, as the strip's fresh lane). + fresh: bool, +} + +/// One block's node spans per source lane and the block's mute jump length (#1652b). A lane with +/// no span (no cell, no timeline, no jump-length cell, or a span at the `i64` edge) holds its +/// curve and emits nothing this block. +#[derive(Clone, Copy)] +struct FollowBlock { + spans: [Option; 2], + jump: u32, +} + +impl FollowBlock { + const IDLE: Self = Self { + spans: [None; 2], + jump: 0, + }; +} + +impl LiveFollowRoute { + /// The bound route (#1652b): its open transform `transform` (the plan's prepared route), its + /// cell's reader `control` and its source `source`. Each automated lane's cursor is seeded with + /// the curve's first `start_value`, and the bound target composes that curve with the seeded + /// flags, as #1651's prepared stage mute does; the ramp is settled on it, so an idle route mixes + /// exactly what a prepared route of that gate mixes. Control side. + pub(crate) fn new( + transform: crate::RouteTransform, + control: Box, + source: &crate::GraphFollowRouteSource, + ) -> Self { + let mut states = [automation::CellState::new(); 2]; + let mut curve = [false; 2]; + for ((state, curve), cell) in states.iter_mut().zip(&mut curve).zip(source.cells) { + let seed = cell + .and_then(|cell| source.program.cell(cell as usize)) + .and_then(|cell| cell.segments().first()) + .map(|segment| segment.start_value); + if let Some(seed) = seed { + state.seed_target(seed); + *curve = seed != 0.0; + } + } + let follow = FollowCursors { + program: std::sync::Arc::clone(&source.program), + cells: source.cells, + states, + curve, + arrival: source.arrival, + mute_jump: source.mute_jump, + fresh: true, + }; + let (target, mute) = follow.compose(&transform, source.flags, curve); + Self { + ramp: IndexedRamp::settled(target), + position: 0, + mute, + delayed: false, + route: u32::MAX, + transform, + flags: source.flags, + control, + follow, + block: FollowBlock::IDLE, + } + } +} + +// REALTIME_POLICY_BEGIN +impl FollowCursors { + /// The plan's `automated` flags: which source lanes have a cell. + const fn automated(&self) -> [bool; 2] { + [self.cells[0].is_some(), self.cells[1].is_some()] + } + + /// [`follow_target`] with the plan's `automated`. + fn compose( + &self, + transform: &crate::RouteTransform, + flags: crate::RouteFollowFlags, + curve: [bool; 2], + ) -> ([f32; 4], bool) { + follow_target(self.automated(), transform, flags, curve) + } + + /// The block's spans at the tap's node time (`node_span(a(tap))`, R17-3) and its mute jump + /// length (the host-core rule over the block's lengths). Takes the fresh flag: the first block + /// with no seek reaching it lands a `Set` at offset 0 on a lane whose curve there differs from + /// what the lane holds. + fn block(&mut self, time: &crate::GraphBlockTime<'_>) -> FollowBlock { + let fresh = core::mem::replace(&mut self.fresh, false); + let frames = time.frames(); + let (Some(span), Some(lengths)) = (time.node_span(self.arrival), time.jump_lengths()) + else { + return FollowBlock::IDLE; + }; + let length = i64::from(frames); + if frames == 0 + || span.start.checked_add(length).is_none() + || span + .step + .is_some_and(|step| step.timeline.checked_add(length).is_none()) + { + return FollowBlock::IDLE; + } + let jump = core::cmp::min((self.mute_jump)(lengths), crate::ROUTE_RAMP_LENGTH_MAXIMUM); + let mut spans = [None; 2]; + for ((slot, cell), state) in spans.iter_mut().zip(self.cells).zip(&self.states) { + let Some(program) = cell.and_then(|cell| self.program.cell(cell as usize)) else { + continue; + }; + let step = span + .step + .map(|step| (step.offset, step.timeline)) + .or_else(|| { + let value = automation::value_at(program.segments(), span.start); + (fresh && state.target().map(f32::to_bits) != Some(value.to_bits())) + .then_some((0, span.start)) + }); + // The graph's step lies inside the block, so the span is valid; a refused one leaves + // the lane holding its curve, with no panic. + *slot = automation::NodeSpan::new(span.start, step, frames).ok(); + } + FollowBlock { spans, jump } + } +} + +/// A following route's target and whether it silences (#1652 D2), for `transform` and `flags` +/// while the curves are at `curve`, with `automated` the plan's: `gated_route_coefficients` of the +/// open transform under [`crate::RouteFollowFlags::gate`]. The one composition of the seed, the +/// cell's block entry and every curve event, so their bits cannot differ. +fn follow_target( + automated: [bool; 2], + transform: &crate::RouteTransform, + flags: crate::RouteFollowFlags, + curve: [bool; 2], +) -> ([f32; 4], bool) { + let gate = crate::RouteFollowFlags { automated, ..flags }.gate(curve); + (gated_route_coefficients(transform, gate), gate.silences()) +} + +/// Walk one block's events of both source lanes in offset order (#1652 D2), with `states` and +/// `curve` advanced as the events come out, and call `change(offset, curve, set)` once per offset +/// that has an event: `curve` after every event at that offset, `set` when one of them is a seek's +/// exact `Set`. A held grid event changes no curve. Bounded by the cells' per-block event bound +/// ([`automation::CellProgram::max_events`]); allocation-, lock- and panic-free. +fn follow_walk( + program: &automation::Program, + cells: [Option; 2], + states: &mut [automation::CellState; 2], + curve: &mut [bool; 2], + block: FollowBlock, + mut change: impl FnMut(u32, [bool; 2], bool), +) { + let [left, right] = states; + let events = |cell: Option, span: Option, state| { + let program = program.cell(cell? as usize)?; + Some(automation::CellEvents::new( + program, + state, + span?, + automation::SeekMode::Exact, + block.jump, + )) + }; + let mut events = [ + events(cells[0], block.spans[0], left), + events(cells[1], block.spans[1], right), + ]; + let mut next = [None; 2]; + for (next, events) in next.iter_mut().zip(&mut events) { + *next = events.as_mut().and_then(Iterator::next); + } + // Each pass consumes at least one event, so the loop ends with the iterators. + while let Some(offset) = next.iter().flatten().map(|event| event.offset).min() { + let mut set = false; + for ((next, events), curve) in next.iter_mut().zip(&mut events).zip(curve.iter_mut()) { + let Some(event) = next.filter(|event| event.offset == offset) else { + continue; + }; + if !event.held { + *curve = event.value != 0.0; + } + set |= event.kind == automation::EventKind::Set; + *next = events.as_mut().and_then(Iterator::next); + } + change(offset, *curve, set); + } +} + +impl LiveFollowRoute { + /// The route op's block entry (#1652 D2): read the cell under the block's snapshot and, on a + /// target whose bits change, retarget from the coefficients the route is at over the + /// record's ramp; take the block's spans at the tap's node time; decide the block's + /// activity. Returns whether the route mixes this block. + /// + /// The cell composes with the curve the route has heard (the curve at the block's first + /// sample before that sample's events), and the block's curve events follow in offset order, + /// as the strip's stage orders them (#1651). Inactive only when the route is muted, past its + /// ramp and undelayed at entry and no event of the block leaves it otherwise: the changes are + /// found first on copies of the cursors, so a route that unmutes inside the block mixes, and a + /// muted route with no change moves no bit. An inactive block commits the copies' cursors (no + /// event of it retargets, since every silencing target is `[+0.0; 4]`). + #[inline] + fn begin_block(&mut self, snapshot: LiveSnapshot, time: &crate::GraphBlockTime<'_>) -> bool { + let mut applied = 0; + if let Some(record) = self.control.read(snapshot) { + self.transform = record.transform(); + self.flags = record.flags(); + let (target, mute) = + self.follow + .compose(&self.transform, self.flags, self.follow.curve); + self.mute = mute; + if target.map(f32::to_bits) != self.ramp.target.map(f32::to_bits) { + self.ramp = IndexedRamp::new( + self.ramp.coefficients_at(self.position), + target, + record.length(), + ); + self.position = 0; + } + applied = 1; + } + #[cfg(any(test, feature = "test-support"))] + test_only_count_route_drained(self.route as usize, applied, snapshot); + let _ = applied; + self.block = self.follow.block(time); + if !(self.mute && self.position >= self.ramp.length && !self.delayed) { + return true; + } + let mut states = self.follow.states; + let mut curve = self.follow.curve; + let mut target = self.ramp.target.map(f32::to_bits); + let mut active = false; + let (follow, transform, flags, jump) = + (&self.follow, &self.transform, self.flags, self.block.jump); + follow_walk( + &follow.program, + follow.cells, + &mut states, + &mut curve, + self.block, + |_, curve, set| { + let (next, mute) = follow.compose(transform, flags, curve); + let next = next.map(f32::to_bits); + // A retarget is to a target that does not silence, so it always activates. + let settled = next == target || set || jump == 0; + target = next; + active |= !(mute && settled); + }, + ); + if !active { + self.follow.states = states; + self.follow.curve = curve; + } + active + } + + /// D2: mix the block, retargeting at each curve event whose target's bits change: mix + /// `[0, o)`, retarget from `coefficients_at(position)` over the block's mute jump length (`0` + /// for a seek's `Set`), mix `[o, q)`. The indexed law makes the split exact: the result is + /// the same block split at `o` with the retarget at the second part's entry, bit for bit. + #[inline] + fn mix(&mut self, left: &mut [f32], right: &mut [f32]) { + let frames = core::cmp::min(left.len(), right.len()); + let mut from = 0_usize; + let automated = self.follow.automated(); + let Self { + ramp, + position, + mute, + transform, + flags, + follow, + block, + .. + } = self; + let FollowCursors { + program, + cells, + states, + curve, + .. + } = follow; + let jump = block.jump; + follow_walk( + program, + *cells, + states, + curve, + *block, + |offset, curve, set| { + let (target, silences) = follow_target(automated, transform, *flags, curve); + *mute = silences; + if target.map(f32::to_bits) == ramp.target.map(f32::to_bits) { + return; + } + let at = core::cmp::min(offset as usize, frames); + mix_follow_part(left, right, from, at, ramp, position); + *ramp = IndexedRamp::new( + ramp.coefficients_at(*position), + target, + if set { 0 } else { jump }, + ); + *position = 0; + from = at; + }, + ); + mix_follow_part(left, right, from, frames, ramp, position); + } +} + +/// Mix frames `[from, to)` of the block by `ramp` from `position`, then advance and saturate +/// `position`. An empty or out-of-block range mixes nothing. +#[inline] +fn mix_follow_part( + left: &mut [f32], + right: &mut [f32], + from: usize, + to: usize, + ramp: &IndexedRamp, + position: &mut u32, +) { + let (Some(left), Some(right)) = (left.get_mut(from..to), right.get_mut(from..to)) else { + return; + }; + if left.is_empty() { + return; + } + route_mix_ramp_block::(left, right, ramp, *position); + let frames = u32::try_from(left.len()).unwrap_or(u32::MAX); + *position = core::cmp::min(position.saturating_add(frames), ramp.length); +} +// REALTIME_POLICY_END + /// The bytes bind allocates for one following route's render-side owner (#1652 D1): its box. #[must_use] pub(crate) const fn live_follow_route_owner_bytes() -> usize { @@ -1291,6 +1649,10 @@ pub(crate) enum NodeKind { /// A route whose coefficients change live (issue #1220 D6): it drains its lane at the start /// of every block, ramps by the indexed ramp and writes its own activity bit. LiveRoute(Box), + /// A route into a submix whose source strip has an automated mute lane (#1652 D1, D2): it reads + /// its seven-word cell at the start of every block, follows the source's stored mute curves in + /// its tap's node time, retargets inside the block and writes its own activity bit. + LiveFollowRoute(Box), /// A homogeneous-bank member: the reduction gathers its input, the bank does the work. BankMember, } @@ -2089,6 +2451,7 @@ impl NodeKind { | Self::SourceInput | Self::Route(_) | Self::LiveRoute(_) + | Self::LiveFollowRoute(_) | Self::BankMember => ChannelSymmetryWitness::SYMMETRIC, } } @@ -3192,7 +3555,9 @@ impl Runtime { match &self.units[index] { RuntimeUnit::Op(op) => match (&op.kind, &*op.inputs) { (NodeKind::Bound(_), _) => BOUND, - (NodeKind::Route(_) | NodeKind::LiveRoute(_), _) => ROUTE, + (NodeKind::Route(_) | NodeKind::LiveRoute(_) | NodeKind::LiveFollowRoute(_), _) => { + ROUTE + } (NodeKind::Identity, [single]) if op.split_pair.is_none() => { if *single == op.output { IDENTITY_ALIAS @@ -3720,6 +4085,17 @@ fn execute_op( return Ok(()); } } + // #1652 D2: a following route reads its cell and finds its block's curve changes first, so a + // route inactive at block entry that unmutes inside the block mixes it. + if let NodeKind::LiveFollowRoute(live) = &mut op.kind { + let active = live.begin_block(snapshot, block_time); + if let Some((activity, _)) = routes.as_mut() { + activity.set_active(live.route as usize, active); + } + if !active { + return Ok(()); + } + } let routes = routes.map(|(activity, unit)| (&*activity, unit)); // Issue #1217 D4: an inactive route's op does not run. A route has one undelayed input // (its compensation is 0), so there is nothing to stage either, and its destination neither @@ -3826,6 +4202,12 @@ fn execute_op( let (out_left, out_right) = output_planes(arena, &mut host, output); live.mix(out_left, out_right); } + NodeKind::LiveFollowRoute(live) => { + #[cfg(any(test, feature = "test-support"))] + test_only_count_route_mix(live.route as usize); + let (out_left, out_right) = output_planes(arena, &mut host, output); + live.mix(out_left, out_right); + } 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. @@ -5951,6 +6333,15 @@ pub(crate) struct RuntimeParts { /// Issue #1220 D6: live-route lanes by route node, taken by `node_kind` for that route's op. /// Empty for a plan without route controls, so every route binds `NodeKind::Route`. route_controls: BTreeMap>, + /// #1652b: following-route lanes and their sources by route node, taken by `node_kind` for + /// that route's op. Empty for a plan with no following route. + follow_route_controls: BTreeMap< + GraphNodeId, + ( + Box, + crate::GraphFollowRouteSource, + ), + >, pub(crate) bindings: BTreeMap>>, pub(crate) split_pairs: BTreeMap, pub(crate) observers: BTreeMap>, @@ -6073,6 +6464,7 @@ impl RuntimeParts { .map(|binding| (binding.node, binding.observation)) .collect(), route_controls: BTreeMap::new(), + follow_route_controls: BTreeMap::new(), bindings: bindings .into_iter() .map(|binding| (binding.node, binding.processor)) @@ -6107,6 +6499,18 @@ impl RuntimeParts { self } + /// #1652b: the plan's following-route lanes, keyed by route node as the six-word lanes are. + pub(crate) fn with_follow_route_controls( + mut self, + controls: Vec, + ) -> Self { + self.follow_route_controls = controls + .into_iter() + .map(|binding| (binding.node, (binding.control, binding.source))) + .collect(); + self + } + /// The node-kind decision used by the sequential executor. /// /// A route's linear gain is folded into its 2x2 coefficients here, once, at bind: render then @@ -6154,13 +6558,21 @@ impl RuntimeParts { let prepared = gated_route_coefficients(&transform, gate); // Issue #1220 D6: a route with a lane is live; its activity index and delay are set // once the plan's route-activity table exists (`bind_live_routes`). - match self.route_controls.remove(node) { - None => NodeKind::Route(prepared), - Some(control) => NodeKind::LiveRoute(Box::new(LiveRoute::new( - prepared, - gate.silences(), - control, - ))), + if let Some((control, source)) = self.follow_route_controls.remove(node) { + // #1652b: a following route composes its own bound target from the open + // transform, the seeded flags and the curves' first values. + NodeKind::LiveFollowRoute(Box::new(LiveFollowRoute::new( + transform, control, &source, + ))) + } else { + match self.route_controls.remove(node) { + None => NodeKind::Route(prepared), + Some(control) => NodeKind::LiveRoute(Box::new(LiveRoute::new( + prepared, + gate.silences(), + control, + ))), + } } } else if let Some([left, right]) = self.track_delays.remove(node) { // A delay entry on a node that is not a source input: a submix's `Input`, which @@ -7236,6 +7648,7 @@ pub(crate) fn build_sequential( let route_gates: Vec<(GraphNodeId, RouteGate)> = if parts.routes.values().any(|(_, gate)| gate.silences()) || !parts.route_controls.is_empty() + || !parts.follow_route_controls.is_empty() { parts .routes @@ -7672,14 +8085,16 @@ fn bind_live_routes(units: &mut [RuntimeUnit], activity: &RouteActivity, delayed let RuntimeUnit::Op(op) = owner else { continue; }; - let NodeKind::LiveRoute(live) = &mut op.kind else { - continue; + let (route_index, delayed_flag) = match &mut op.kind { + NodeKind::LiveRoute(live) => (&mut live.route, &mut live.delayed), + NodeKind::LiveFollowRoute(live) => (&mut live.route, &mut live.delayed), + _ => continue, }; let route = activity .route_of(unit) .expect("a live route's op is a route unit of the activity table"); - live.route = u32::try_from(route).expect("a route index fits u32"); - live.delayed = delayed.get(route).copied().unwrap_or(false); + *route_index = u32::try_from(route).expect("a route index fits u32"); + *delayed_flag = delayed.get(route).copied().unwrap_or(false); } } @@ -8547,7 +8962,7 @@ impl PlanningMetadata for RuntimeParts { self.observers.contains_key(node) } fn has_route_control(&self, node: &GraphNodeId) -> bool { - self.route_controls.contains_key(node) + self.route_controls.contains_key(node) || self.follow_route_controls.contains_key(node) } fn route(&self, node: &GraphNodeId) -> Option<(&RouteTransform, RouteGate)> { self.routes @@ -8611,6 +9026,11 @@ impl<'a> BorrowedPlanningMetadata<'a> { .iter() .flatten() .map(|binding| &binding.node) + .chain( + plan.follow_route_controls + .iter() + .map(|binding| &binding.node), + ) .collect(), } } @@ -16474,3 +16894,401 @@ mod tests { } } } + +#[cfg(test)] +mod follow_route_op_tests { + //! #1652b: the following route op, driven directly at its block entry and its mix: the + //! in-block retarget (gate 3), the activity of a route that unmutes inside a block, the + //! event's time at the tap's arrival, the redundant-retarget guard and a seek. The route op is + //! not banked and mixes at `FrameLane`, so no case depends on a bank width. + use super::{FrameLane, IndexedRamp, LiveFollowRoute, route_mix_ramp_block}; + use crate::follow_route_tests::{mute_source, steps}; + use crate::{ + FollowRouteControlRecord, GraphBlockTime, GraphJumpLengths, GraphPreparedSourceSetDriver, + RenderError, RouteFollowFlags, RouteGate, RouteTransform, StableGraphId, TimelineSpan, + TimelineStep, gated_route_coefficients, + }; + use engine::realtime::{GatePin, LiveValuesSuperseded, RevisionGate}; + + /// The session's mute jump length in these cases. + const MUTE: u32 = 48; + /// The live record's ramp at the block entry, long enough to run through every block. + const RECORD_RAMP: u32 = 256; + /// The block's first source-read sample `r + ΣP`. + const FIRST: u64 = 1_000; + + /// An identity timeline (`timeline(s) = s`), with an optional seek step. + struct Timeline { + seek: Option, + } + + impl GraphPreparedSourceSetDriver for Timeline { + fn claim_count(&self) -> usize { + 0 + } + fn begin_block(&mut self, _first_sample: u64, _frames: u32) -> Result<(), RenderError> { + Ok(()) + } + fn copy_track_input( + &mut self, + _claim_index: usize, + _left: &mut [f32], + _right: &mut [f32], + ) -> Result<(), RenderError> { + Ok(()) + } + fn timeline_block(&self, first: i128, _frames: u32) -> Option { + Some(TimelineSpan { + start: i64::try_from(first).ok()?, + step: self.seek, + }) + } + } + + const OPEN: RouteTransform = RouteTransform { + gain: 0.75, + ll: 0.9, + lr: 0.2, + rl: -0.3, + rr: 0.8, + }; + const MOVED: RouteTransform = RouteTransform { + gain: 1.25, + ll: 0.4, + lr: -0.6, + rl: 0.5, + rr: 0.1, + }; + + fn gated(transform: RouteTransform, follow_zeroed: [bool; 2]) -> [f32; 4] { + gated_route_coefficients( + &transform, + RouteGate { + mute: false, + follow_zeroed, + }, + ) + } + + struct Fixture { + route: Box, + producer: crate::GraphFollowRouteControlProducer, + gate: RevisionGate, + pin: GatePin, + revision: u64, + } + + impl Fixture { + /// A route of transform `transform` whose source lanes have the given mute curves, at + /// `a(tap) = arrival`. + fn new( + lanes: [Option<&[automation::Segment]>; 2], + arrival: u64, + transform: RouteTransform, + ) -> Self { + let source = mute_source(lanes, arrival); + let (binding, producer) = crate::follow_route_control_pair( + &StableGraphId::parse("send").expect("ID"), + &LiveValuesSuperseded::new(), + source, + 0, + 0, + ); + let route = Box::new(LiveFollowRoute::new( + transform, + binding.control, + &binding.source, + )); + let (gate, pin) = RevisionGate::pair(0); + Self { + route, + producer, + gate, + pin, + revision: 0, + } + } + + /// Write and publish one record: `transform`, the source's own flags, over `length`. + fn write(&mut self, transform: RouteTransform, length: u32) { + self.revision += 1; + let record = + FollowRouteControlRecord::new(transform, self.route.flags, length).expect("record"); + self.producer + .write(self.pin.stamp(self.revision).expect("stamp"), record); + self.pin.publish(&self.gate, self.revision); + } + + /// The route op's block of `left.len()` samples whose first source-read sample is + /// `first`: its block entry, then its mix when active. Returns the activity. + fn block( + &mut self, + first: u64, + seek: Option, + left: &mut [f32], + right: &mut [f32], + ) -> bool { + let timeline = Timeline { seek }; + let lengths = GraphJumpLengths { + fader_samples: 480, + mute_samples: MUTE, + pan_samples: 960, + }; + let frames = u32::try_from(left.len()).expect("frames"); + let time = GraphBlockTime::new(Some(first), frames, Some(lengths), Some(&timeline)); + let active = self.route.begin_block(self.gate.snapshot(), &time); + if active { + self.route.mix(left, right); + } + active + } + } + + fn input(frames: usize) -> (Vec, Vec) { + let left = (0..frames) + .map(|i| ((i * 37 % 101) as f32 - 50.0) / 17.0) + .collect(); + let right = (0..frames) + .map(|i| ((i * 53 % 97) as f32 - 48.0) / 13.0) + .collect(); + (left, right) + } + + fn bits(planes: (&[f32], &[f32])) -> Vec { + planes + .0 + .iter() + .chain(planes.1) + .map(|x| x.to_bits()) + .collect() + } + + /// The reference: mix `[0, o)` by `first` from position 0, then retarget to `target` over + /// `length` from `first`'s coefficients at `o`, and mix `[o, q)` from position 0. + fn split_reference( + first: IndexedRamp, + o: usize, + target: [f32; 4], + length: u32, + frames: usize, + ) -> Vec { + let (mut left, mut right) = input(frames); + let (left_a, left_b) = left.split_at_mut(o); + let (right_a, right_b) = right.split_at_mut(o); + route_mix_ramp_block::(left_a, right_a, &first, 0); + let at = u32::try_from(o).expect("offset"); + let second = IndexedRamp::new(first.coefficients_at(at), target, length); + route_mix_ramp_block::(left_b, right_b, &second, 0); + bits((&left, &right)) + } + + /// Gate 3. A route mid-ramp (a live record at the block entry) whose source's left mute steps + /// on at offset `o` retargets there from the coefficients it is at: the block equals the same + /// block split at `o` with the retarget at the second part's entry, bit for bit, for `o` in + /// {1, 63, 64, 127} at `q = 128` and {1, 63, 64, 99} at `q = 100`, and equals the route op + /// run as two blocks split at `o`. + /// + /// Test value: red if the in-block retarget restarts the indexed ramp from its target or from + /// rest (its start) instead of `coefficients_at(position)`, if `position` is not advanced by + /// the first part, or if the retarget lands one sample early or late. + #[test] + fn a_retarget_inside_the_block_equals_the_block_split_at_its_offset() { + for (frames, offsets) in [(128_usize, [1, 63, 64, 127]), (100, [1, 63, 64, 99])] { + for o in offsets { + let step = FIRST + o as u64; + let curve = steps(&[0, step], &[0.0, 1.0], 1 << 20); + let expected = split_reference( + IndexedRamp::new( + gated(OPEN, [false; 2]), + gated(MOVED, [false; 2]), + RECORD_RAMP, + ), + o, + gated(MOVED, [true, false]), + MUTE, + frames, + ); + + let mut whole = Fixture::new([Some(&curve), None], 0, OPEN); + whole.write(MOVED, RECORD_RAMP); + let (mut left, mut right) = input(frames); + assert!(whole.block(FIRST, None, &mut left, &mut right)); + assert_eq!(bits((&left, &right)), expected, "q {frames}, o {o}: whole"); + + let mut split = Fixture::new([Some(&curve), None], 0, OPEN); + split.write(MOVED, RECORD_RAMP); + let (mut left, mut right) = input(frames); + let (left_a, left_b) = left.split_at_mut(o); + let (right_a, right_b) = right.split_at_mut(o); + assert!(split.block(FIRST, None, left_a, right_a)); + assert!(split.block(step, None, left_b, right_b)); + assert_eq!(bits((&left, &right)), expected, "q {frames}, o {o}: split"); + } + } + } + + /// A route muted by its source's curve on both lanes, settled and undelayed, is inactive in a + /// block with no curve change (it mixes nothing); in a block whose curve unmutes at offset + /// `o` it is active and mixes `[o, q)` from the silent coefficients toward the open ones over + /// the mute length. Before `o` it mixes the silent coefficients. + /// + /// Test value: red if the activity is decided at the block entry before the block's changes + /// are found (the unmute would be lost for the block), or if a muted block with no change is + /// active "to be safe". + #[test] + fn a_route_inactive_at_block_entry_that_unmutes_inside_the_block_mixes_the_rest() { + let frames = 128_usize; + for o in [1_usize, 63, 64, 127] { + let unmute = FIRST + frames as u64 + o as u64; + let curve = steps(&[0, unmute], &[1.0, 0.0], 1 << 20); + let mut route = Fixture::new([Some(&curve), Some(&curve)], 0, OPEN); + assert!(route.route.mute, "the seeded curve silences the route"); + + let (mut left, mut right) = input(frames); + let untouched = bits((&left, &right)); + assert!( + !route.block(FIRST, None, &mut left, &mut right), + "no change: inactive" + ); + assert_eq!( + bits((&left, &right)), + untouched, + "an inactive block mixes nothing" + ); + + let (mut left, mut right) = input(frames); + assert!( + route.block(FIRST + frames as u64, None, &mut left, &mut right), + "o {o}: an unmute inside the block activates it" + ); + let expected = split_reference( + IndexedRamp::settled([0.0; 4]), + o, + gated(OPEN, [false; 2]), + MUTE, + frames, + ); + assert_eq!(bits((&left, &right)), expected, "o {o}"); + assert!(!route.route.mute); + } + } + + /// A curve event at timeline sample `t` falls at the render sample where the tap carries `t`: + /// with `a(tap) = 64`, a step at `FIRST + 10` lands at offset 74 of the block whose first + /// source-read sample is `FIRST`, not at offset 10. + /// + /// Test value: red if the route op times its events at another arrival than its tap's (the + /// fader's or the route's own, `a = 0` here), the leak a pre-insert send would show. + #[test] + fn the_event_lands_where_the_tap_carries_the_curve_sample() { + let frames = 128_usize; + let arrival = 64; + let curve = steps(&[0, FIRST + 10], &[0.0, 1.0], 1 << 20); + let mut route = Fixture::new([Some(&curve), None], arrival, OPEN); + let (mut left, mut right) = input(frames); + assert!(route.block(FIRST, None, &mut left, &mut right)); + let expected = split_reference( + IndexedRamp::settled(gated(OPEN, [false; 2])), + 10 + arrival as usize, + gated(OPEN, [true, false]), + MUTE, + frames, + ); + assert_eq!(bits((&left, &right)), expected); + } + + /// An event whose composed target has the bits the route already aims at retargets nothing: + /// a mid-ramp route whose left column is already zero keeps its ramp through its source's + /// left mute, and the block equals the ramp run straight through. + /// + /// Test value: red if every curve event retargets (a redundant retarget restarts the ramp at + /// `coefficients_at(position)` over the mute length and moves bits, README "Change only"). + #[test] + fn an_event_that_changes_no_target_bit_retargets_nothing() { + let frames = 128_usize; + let zero_left = RouteTransform { + ll: 0.0, + rl: 0.0, + ..MOVED + }; + let curve = steps(&[0, FIRST + 40], &[0.0, 1.0], 1 << 20); + let mut route = Fixture::new([Some(&curve), None], 0, OPEN); + route.write(zero_left, RECORD_RAMP); + let (mut left, mut right) = input(frames); + assert!(route.block(FIRST, None, &mut left, &mut right)); + assert_eq!( + gated(zero_left, [true, false]).map(f32::to_bits), + gated(zero_left, [false; 2]).map(f32::to_bits), + "the left mute changes no coefficient bit" + ); + let (mut expected_left, mut expected_right) = input(frames); + let ramp = IndexedRamp::new( + gated(OPEN, [false; 2]), + gated(zero_left, [false; 2]), + RECORD_RAMP, + ); + route_mix_ramp_block::(&mut expected_left, &mut expected_right, &ramp, 0); + assert_eq!( + bits((&left, &right)), + bits((&expected_left, &expected_right)) + ); + assert_eq!(route.route.ramp, ramp, "the ramp in flight is kept"); + } + + /// A seek that reaches the tap at offset `s` into a span where the source's left mute is on + /// sets the target exactly there: the frames from `s` on mix the gated target, with no ramp. + /// + /// Test value: red if the seek's landing ramps over the mute length (a `Jump` landing instead + /// of the mute cell's exact `Set`), or if it lands at another offset than the seek's. + #[test] + fn a_seek_sets_the_target_exactly_where_it_reaches_the_tap() { + let frames = 128_usize; + let s = 37_u32; + let curve = steps(&[0, 50_000], &[0.0, 1.0], 1 << 20); + let mut route = Fixture::new([Some(&curve), None], 0, OPEN); + let (mut left, mut right) = input(frames); + let seek = TimelineStep { + offset: s, + timeline: 60_000, + }; + assert!(route.block(FIRST, Some(seek), &mut left, &mut right)); + let expected = split_reference( + IndexedRamp::settled(gated(OPEN, [false; 2])), + s as usize, + gated(OPEN, [true, false]), + 0, + frames, + ); + assert_eq!(bits((&left, &right)), expected); + assert_eq!(route.route.ramp.length, 0, "a step, no ramp"); + } + + /// The bound route composes its target from the open transform, the seeded flags and each + /// automated lane's first curve value, settled, with the plan's `automated` flags. + /// + /// Test value: red if the seed ignores the curve's first value (a session that starts muted + /// would pass audio until its first event) or reads a lane the plan does not automate. + #[test] + fn the_bound_target_composes_the_curves_first_values() { + let on = steps(&[0, 4_096], &[1.0, 0.0], 1 << 20); + let off = steps(&[0, 4_096], &[0.0, 1.0], 1 << 20); + for (lanes, zeroed) in [ + ([Some(&on[..]), None], [true, false]), + ([None, Some(&on[..])], [false, true]), + ([Some(&off[..]), Some(&on[..])], [false, true]), + ] { + let route = Fixture::new(lanes, 0, OPEN); + assert_eq!(route.route.ramp, IndexedRamp::settled(gated(OPEN, zeroed))); + assert_eq!( + route.route.flags, + RouteFollowFlags { + follows_mute: true, + automated: lanes.map(|lane| lane.is_some()), + ..RouteFollowFlags::default() + } + ); + } + let both = Fixture::new([Some(&on), Some(&on)], 0, OPEN); + assert!(both.route.mute); + } +} From e7848d05a6645cc4d9e460b9a4595956d3643908 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 07:21:44 +0000 Subject: [PATCH 05/23] Split the fader row from the rendered automation rows (#1651 T2) controls::fader_row names the strip fader row. The readers that mean the fader (fader_automation, model_fader_offsets, mask_fader_offsets and the conservative B17-R14 fallback predicate) now test it, so the mute row that T3a renders gives no lane a hand offset, turns no fader edit into an offsets write and flags no mute-only rebuild (R18-4: a mute adoption never stretches a live move). automation_row_renders is still the fader row alone; no rendered bit moves. Gate a_mute_entry_reaches_no_fader_only_reader. Co-Authored-By: Claude Opus 5.5 --- .../1651-render-stored-mute-automation.md | 40 +++++++++++ crates/control-plane/src/control.rs | 6 +- crates/controls/src/automation.rs | 23 +++++-- crates/controls/src/effective.rs | 11 ++-- crates/controls/src/lib.rs | 4 +- crates/controls/src/mask.rs | 19 +++--- crates/controls/tests/automation.rs | 66 +++++++++++++++++++ 7 files changed, 145 insertions(+), 24 deletions(-) diff --git a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md index f042ec902..593043338 100644 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md @@ -369,3 +369,43 @@ PR #1712). mechanical to the new rule. - **Measured.** `audit capi` `pcm_digest` `bffc21e857b5e134` (base: the same), `allocations` 0. - **Departures.** None from the rule's text. + +## Decision record: T2 (batch 18) + +- **Shipped (no rendered bit moves).** A new `controls::fader_row(target)` (strip, builtins, row + 5) is the fader-only predicate; `automation_row_renders` is today `fader_row(target)` and T3a + adds row 6 to it alone. The fader-only readers now test `fader_row`. +- **Reader classification** (every reader of `automation_row_renders` at `0e078aad8`): + - Fader-only, now `fader_row`: `fader_automation` (`automation.rs`; also feeds + `continued_fader_lanes`, `ModelView::fader_automation`, `OwnerEdits::take_fader_automation` + and builtins-compiler's per-strip cells), `model_fader_offsets` (`effective.rs`; feeds + `EffectiveStrip::fader_offset`/`offsets_sum` and `mask_values`' automated lanes), + `mask_fader_offsets` (`mask.rs`), and B17-R14's `automates` (`control-plane/src/control.rs`). + - Any rendered row, unchanged: `rendered_automation_changed` (a mute entry change must rebuild, + D2), and `automated_cell` (keyed by the address's own row: it already compares + `target.control == address.control`, so `automated_fader_lanes`, its one caller, asks at + `fader_db` and a mute entry never matches; at a mute address it will say where T3a's mute cell + exists, which is the doc's claim). + - Any rendered row, outside T2's files: `prepare_fader_automation`'s early return + (`builtins-compiler/src/lib.rs:2095`). It is right for T3a (the program then also builds mute + cells); until T3a it is the same predicate. +- **R18-4 (root, accepted).** B17-R14's window stays fader-only because a mute adoption never + stretches a live move: a mute cell's adoption jumps over the mute jump length and has no level + ramp in flight to land over `J`, so a successor that automates only mute rows completes `Exact`. +- **Gate.** `crates/controls/tests/automation.rs::a_mute_entry_reaches_no_fader_only_reader`: a + `both` mute entry before a `left` fader entry on `vocal`; the fader cells are the fader entry's + left lane only, `automated_fader_lanes == [true, false]`, the right lane has no hand offset and + the same `S` bits as the session without the mute entry, and `mask_values` masks the right + lane's static dB. Row 6 cannot render before T3a, so the test is green on the base too; its + predicate asserts (`fader_row` refuses a mute target, every fader target renders) hold the split + now. Probe (not committed): with row 6 added to `automation_row_renders`, the test stays green + with the split and turns red with `fader_automation` (the mute entry takes the left cell) or + `model_fader_offsets` (`[Some(0.0), Some(0.0)]`) reverted to the old predicate. + `mask_fader_offsets` reverted is not observable: a validated mute entry holds `+0.0` offsets, + so copying one moves nothing. The control-plane `automates` change has no T2 test (no mute row + renders yet); T3a's gate 8 (a mute-only rebuild with a fader move in flight completes `Exact`) + holds it. +- **Ran.** `cargo test --locked -p controls --all-features`, `-p control-plane --features + test-support`, `-p host-core --features host-core/test-support`, `-p capi`: all pass. fmt, + clippy (`controls`, `control-plane`, all targets and features) and the no-feature check pass. +- **Departures.** `automated_cell` is not changed (above). None else. diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 4f75dd8fd..e51c15754 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -2601,7 +2601,9 @@ where // 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. + // the window with the commit. A declared discontinuity carries nothing. The test is the + // fader row alone (#1651 T2, root ruling R18-4): a mute cell's adoption never stretches a + // live move, so a successor that automates only a mute row completes `Exact`. let (outcome, rebuild_level) = match &kind { Rebuild::Transaction(prepared) => { let session = prepared.get().prospective_session(); @@ -2613,7 +2615,7 @@ where let automates = next .automation .iter() - .any(|entry| controls::automation_row_renders(&entry.target)); + .any(|entry| controls::fader_row(&entry.target)); let in_flight = self.level_moves.is_some_and(|window| { window.may_ramp( self.shared.watermark.read().ok(), diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index c0a3c1f69..1e9b6449e 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -376,11 +376,24 @@ pub const FADER_CELL_RULE: CellRule = CellRule { }; /// 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. +/// (builtin row 5, [`fader_row`]). 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. +/// +/// A reader that means "the fader" tests [`fader_row`] instead (#1651 T2): a rendered row added +/// here (the mute row, #1651) gives no lane a fader cell, a hand-move offset or B17-R14's window. #[must_use] pub fn automation_row_renders(target: &ControlAddress) -> bool { + fader_row(target) +} + +/// Whether `target` is a strip's builtin `fader_db` row (row 5): the one rendered row whose entry +/// gives a lane its fader cell ([`fader_automation`]), its hand-move offset (#1650) and the +/// B17-R14 transition window. The readers of those test this predicate, not +/// [`automation_row_renders`], so an entry on another rendered row reaches none of them (#1651 +/// T2). +#[must_use] +pub fn fader_row(target: &ControlAddress) -> bool { matches!(target.owner, Owner::Strip(_)) && target.section == Section::Builtins && target.control == FADER_DB @@ -397,9 +410,7 @@ pub fn fader_automation<'a>( 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) - { + if !fader_row(target) || !matches!(&target.owner, Owner::Strip(owner) if owner == strip) { continue; } for (index, lane) in [Lane::Left, Lane::Right].into_iter().enumerate() { diff --git a/crates/controls/src/effective.rs b/crates/controls/src/effective.rs index c68378a84..223209189 100644 --- a/crates/controls/src/effective.rs +++ b/crates/controls/src/effective.rs @@ -205,8 +205,8 @@ pub struct EffectiveStrip { /// `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. + /// The hand-move offset of the fader-row stored automation entry ([`crate::fader_row`]) that + /// drives each lane (#1650 D3), or `None` for a lane no fader-row entry drives. pub fader_offset: [Option; 2], } @@ -483,8 +483,9 @@ 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. +/// the automation table ([`crate::fader_automation`]'s rule: the first fader-row entry, +/// [`crate::fader_row`], that drives a lane), or an empty table when no entry is on a fader row. +/// An entry on another rendered row (the mute row, #1651) gives no lane an offset. pub(crate) fn model_fader_offsets(model: &SessionModel) -> Vec<[Option; 2]> { let mut offsets = Vec::new(); for entry in &model.automation { @@ -492,7 +493,7 @@ pub(crate) fn model_fader_offsets(model: &SessionModel) -> Vec<[Option; 2]> let session::Owner::Strip(id) = &target.owner else { continue; }; - if !crate::automation_row_renders(target) { + if !crate::fader_row(target) { continue; } let Some(index) = crate::registry::strip_index(model, id) else { diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index b64e0d069..f019f52bf 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -47,8 +47,8 @@ mod view; pub use automation::{ CellRule, ControlDiagnostic, FADER_CELL_RULE, automated_cell, automated_fader_lanes, automation_diagnostics, automation_row_renders, continued_fader_lanes, fader_automation, - filter_statics_changed_under_automation, filter_under_automation, rendered_automation_changed, - stored_segments, + fader_row, filter_statics_changed_under_automation, filter_under_automation, + rendered_automation_changed, stored_segments, }; pub use descriptor::{ AutomationRule, Choice, ControlDescriptor, ControlScope, DomainRule, Lanes, Motion, check, diff --git a/crates/controls/src/mask.rs b/crates/controls/src/mask.rs index 9b8a14186..7ac5a6e88 100644 --- a/crates/controls/src/mask.rs +++ b/crates/controls/src/mask.rs @@ -47,10 +47,10 @@ 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 fader lane that a fader-row stored automation ([`crate::fader_row`]) 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 @@ -58,7 +58,7 @@ struct Target<'a> { 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. + // One pass over the table: the lanes each strip's fader-row entries drive, by strip index. let driven = crate::effective::model_fader_offsets(into); let automated = |index: usize| { driven @@ -131,12 +131,13 @@ 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. +/// Copy the hand-move offset of every fader-row entry ([`crate::fader_row`]) 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. An entry on another rendered row (the mute +/// row, #1651) carries no offset, so it is not masked. 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) { + if !crate::fader_row(&entry.target) { continue; } if let Some(prior) = pair(&from.automation, index, |candidate| { diff --git a/crates/controls/tests/automation.rs b/crates/controls/tests/automation.rs index e3d412f38..b59acc2b7 100644 --- a/crates/controls/tests/automation.rs +++ b/crates/controls/tests/automation.rs @@ -821,3 +821,69 @@ fn the_offset_belongs_to_a_driven_fader_lane() { ] ); } + +/// #1651 T2 (the predicate split): the readers that mean "the fader" test the fader row +/// ([`controls::fader_row`]), not every rendered row ([`controls::automation_row_renders`]). On +/// `vocal`, a `both` mute entry (row 6) listed before a `left` fader entry: the fader cells +/// ([`controls::fader_automation`], [`controls::automated_fader_lanes`]) are the left fader entry +/// alone, the right lane has no hand offset and its `S` is that of a session without the mute +/// entry ([`controls::effective`]), and [`controls::mask_values`] masks the right lane's static +/// fader dB as a control value (a fader edit there stays a `FaderDb` record, not an offsets write). +/// +/// Red when the mute row joins [`controls::automation_row_renders`] (#1651 T3a) and any of these +/// readers still tests that list: the mute entry, first in the table, would take both lanes' +/// fader cells, give the right lane a `+0.0` hand offset, and keep the right lane's static dB +/// out of the mask. Before T3a the mute row does not render, so the predicate asserts hold the +/// split itself: the fader row admits no mute target, and every fader target renders. +#[test] +fn a_mute_entry_reaches_no_fader_only_reader() { + use controls::{ + MonitorOverlay, automated_fader_lanes, automation_row_renders, effective, fader_automation, + fader_row, mask_values, + }; + + let mute = strip_builtin("vocal", MUTE, Lane::Both); + let fader = strip_builtin("vocal", FADER_DB, Lane::Left); + assert!(!fader_row(&mute), "the mute row is not the fader row"); + for lane in [Lane::Left, Lane::Right, Lane::Both] { + let target = strip_builtin("vocal", FADER_DB, lane); + assert!(fader_row(&target) && automation_row_renders(&target)); + } + assert!(!fader_row(&strip_builtin("vocal", TRIM_DB, Lane::Left))); + + let entries = vec![ + one("mute-ride", mute, ParameterUnit::Linear, 1.0), + with_offset(one("fader-ride", fader, ParameterUnit::Db, -6.0), 2.0, 0.0), + ]; + let mut model = builtins_fixture(); + model.automation = entries; + let vocal = id("vocal"); + + let lanes = fader_automation(&model, &vocal); + assert_eq!(lanes[0].map(|entry| entry.id.as_str()), Some("fader-ride")); + assert!(lanes[1].is_none(), "the mute entry drives no fader cell"); + assert_eq!(automated_fader_lanes(&model, &vocal), [true, false]); + + let overlay = MonitorOverlay::default(); + let strip = effective(&model, &overlay).strips[0]; + assert_eq!(strip.fader_offset, [Some(2.0), None]); + let mut fader_only = model.clone(); + fader_only.automation.remove(0); + let reference = effective(&fader_only, &overlay).strips[0]; + assert_eq!( + strip.offsets_sum.map(f64::to_bits), + reference.offsets_sum.map(f64::to_bits), + "the mute entry moves no lane's S" + ); + + // The mask: `from` moves both static fader lanes. The automated left lane keeps `into`'s + // static dB (its fallback); the right lane, which only the mute entry names, takes `from`'s. + let mut from = model.clone(); + from.tracks[0].fader.left_db = -9.0; + from.tracks[0].fader.right_db = -9.0; + let mut into = model.clone(); + let kept = into.tracks[0].fader.left_db; + mask_values(&mut into, &from); + assert_eq!(into.tracks[0].fader.left_db.to_bits(), kept.to_bits()); + assert_eq!(into.tracks[0].fader.right_db.to_bits(), (-9.0f32).to_bits()); +} From 3d063f0ed92ffc509e2660bf454e27eb680689d5 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 07:35:19 +0000 Subject: [PATCH 06/23] Carry a following route's ramp by route ID across a plan swap (#1652d, graph side) A live route carries its ramp state by route ID whatever its kind in either plan (six->seven, seven->seven, seven->six). Follow cursors are not carried: the successor seeds them at its own node time, applies the join's record first, then compares its composed target with the adopted ramp's target and retargets over the mute length only when the bits differ. A seven->six successor makes the same comparison against its prepared target on its first block, so removing the curve never leaves a send muted. Six->six keeps LiveRoute::adopt and its bits. The swap-block allocation half of gate 4 stays with the host-core gate. Co-Authored-By: Claude Opus 5.5 --- ...mated-mute-on-following-sends-in-render.md | 76 +++ crates/graph/src/lib.rs | 37 +- crates/graph/src/runtime.rs | 643 ++++++++++++++++-- 3 files changed, 678 insertions(+), 78 deletions(-) diff --git a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md index c26082938..9ccf712bc 100644 --- a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md +++ b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md @@ -457,3 +457,79 @@ Tranche R2: the following route op in render. All in `crates/graph`; no attach, the seeded flags (route mute, `follows_mute`, `terms` from `controls::effective`, `automated == cells`). Fill `follow_route_controls`, leave those routes out of `route_controls`, and charge them. The carry (`live_route_rows`, `carry_live_routes`, D3) does not list following routes yet. + +## Decision record: #1652d graph carry (batch 18) + +Tranche R5a: the graph side of D3 (carry). All in `crates/graph`; host-core's join records are a +later tranche (after #1653). + +- **Shipped (`crates/graph/src/runtime.rs`).** `live_route_rows`, `is_live_route`, + `can_carry_live_routes` and `carry_live_routes` take a live route of either kind, so a route + present in both plans carries by route ID whatever its kind in either plan (six -> six, six -> + seven, seven -> seven, seven -> six). One per-move function, `carry_route`, applies the + predecessor's pending value under every snapshot (six-word `apply_pending`, seven-word + `apply_cell`, the cell half of the block entry factored out), then the successor takes ramp, + position and mute. Six -> six still runs `LiveRoute::adopt`, unchanged, so its bits hold. +- **Seven-word successor.** `LiveFollowRoute::adopt` marks the route `FollowStart::Carried` (the old + `fresh: bool` is now `Fresh | Carried | Running`). The follow cursors do not carry: the + successor's own unplaced cursors are placed at its first block's `τ_tap`, and each automated + lane is seeded with `value_at(span.start)` there (no fresh `Set`). The block entry now takes the + spans first, then the cell (the join's record, if any, applies first through the cell and + composes with the curve at `τ_tap`), then compares the composed target with the adopted ramp's + target and retargets from `coefficients_at(position)` over the block's mute jump length only on a + bits change. For a fresh or running route the new order changes nothing: the spans step reads no + state the cell writes. The block's jump length is now resolved whenever the block has jump + lengths, even with no node span; with no span the block has no events, so only the carried + comparison reads it. +- **Six-word successor of a seven-word predecessor (the curve removed).** The swap block has no + jump lengths, so the comparison waits for the first block, and a six-word route holds no mute + rule. At the swap, `carry_route` reads the successor's prepared target and mute (its bound, + settled ramp: it has rendered no block), adopts the state with `LiveRoute::adopt_state`, and when + the bits differ leaves a `SixWordCarryCheck { prepared, mute, mute_jump }` (the predecessor's + host-core mute rule) in that move's `RouteCarryStep`. With equal bits it takes the prepared mute + and leaves nothing. Render's first block after the swap (`GraphExecutor::render`, before the + units, under `route_carry_pending`) runs `finish_route_carry`: per check, the route's cell first + (`apply_pending` under the block's snapshot); with a record applied, the record governs (it is + this plan's own value); without one, `retarget_carried` to the prepared target over + `mute_jump(block lengths)` (clamped to `ROUTE_RAMP_LENGTH_MAXIMUM`, `0` with no lengths) on a bits + change. A send the curve held muted therefore reopens over the session mute length. +- **Bytes.** `LIVE_ROUTE_OWNER_BYTES` and `LIVE_FOLLOW_ROUTE_OWNER_BYTES` are unchanged (the + amendment's "every six-word route keeps its bytes"): the check lives in the carry section's move + table, `carry_routes: Box<[RouteCarryStep]>` (move + `Option`, about 40 bytes + on x86-64 against 8 before), allocated by `install_route_carry` on the control thread and charged + by `route_carry_retained_bytes`, the one place the charge is spelled. The executor and its layout + twin gain one `route_carry_pending: bool`. +- **No `TransitionFallback` (R18-4).** The carry never stretches or cuts a move. A carried ramp + whose target bits do not change keeps its own ramp and length. A changed target is either the + join's record (its own rule and length, as at any block entry) or the curve's own jump at the + successor's node time over the session mute length, which an uninterrupted plan would make at a + curve event (for seven -> six: the jump the removed curve's absence makes, the same length). +- **Realtime.** The swap and the first block copy bounded fields and compare bits: no allocation, + free, lock, syscall or new panic path. Every new render function sits in an existing marked + region; the realtime policy reports 165 marked regions (unchanged). +- **Hook for the host-core join (#1652 D3, after #1653).** None new: the join feeds the successor's + own cell before the swap. For a seven-word successor it writes the seven-word record from + `route_record_between` through the successor's `GraphFollowRouteControlProducer::write` under the + carry's stamp (today's loop at `prepare.rs` writes only `route_controls`; a seven-word successor + with a retarget would fail there with `host.control.order` and must go to the follow producer). + For a six-word successor it writes the six-word record as today. Render applies the record first + at the successor's first block and then compares. `resolve_route_carry` needs no change: the + rows now list following routes. +- **Departure.** The alloc half of the gate is not in graph: linking `bench_support::alloc` into the + graph lib test binary installs the audited allocator for every unit test, and + `a_folded_metered_plan_is_the_unfolded_plans_master_and_meters_bit_for_bit` then aborts. The + route op is `pub(crate)` and following routes cannot be attached from outside graph before + #1652c, so the swap-block `allocations == 0 && frees == 0` gate stays with host-core gate 4. A + seven -> six successor's first block calls `apply_pending` twice (once in `finish_route_carry`, + once in its op), so the test-only drain-call counter counts two calls for that route in that + block; the applied count is exact. +- **Gates (`crates/graph/src/runtime.rs` `follow_route_op_tests::carry`, all pass).** + `six_to_seven_keeps_the_ramp_state_by_route_id` ((a) a pure re-adoption mid-ramp equals the + never-swapped twin bit for bit and keeps ramp and position; (b) a curve on at the swap ramps from + where the route is over the mute length); `seven_to_seven_mid_ramp_keeps_its_position`; + `seven_to_six_reopens_a_send_the_removed_curve_held_muted`. Red checks (temporary mutations, + reverted): no adopt into a seven-word successor -> six->seven and seven->seven red; no bits + guard on the carried comparison -> the same two red; cursor not seeded at `τ_tap` -> six->seven + (b) red; carried comparison as a step -> six->seven (b) red; six-word comparison missing -> + seven->six red. Six -> six carry keeps today's code path; `host-core` `route_mute` (9) and + `live_routes` (13) pass. diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index fb0b24d1a..6a7175617 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -3913,8 +3913,12 @@ struct GraphExecutor { /// (and never allocated) when no strip delay line carries. carry_strip_delays: Box<[GraphStripDelayMove]>, /// The live-send section of the hand-over (#1284 D2), sorted by predecessor unit; empty (and - /// never allocated) when no live route carries. - carry_routes: Box<[GraphRouteMove]>, + /// never allocated) when no live route carries. Each move holds the comparison a six-word + /// successor of a following predecessor leaves for its first block (#1652 D3). + carry_routes: Box<[runtime::RouteCarryStep]>, + /// Some move of `carry_routes` left a comparison for the first block after the swap (#1652 + /// D3); taken by that block. + route_carry_pending: bool, /// The identity of every strip delay line and live route, read once by /// [`take_strip_delay_lines`] and [`take_live_routes`] right after bind and empty afterwards: /// a plan retains none of it. @@ -4970,7 +4974,10 @@ pub fn install_route_carry( } } moves.sort_unstable_by_key(|step| step.predecessor); - executor.carry_routes = moves.into_boxed_slice(); + executor.carry_routes = moves + .into_iter() + .map(runtime::RouteCarryStep::new) + .collect(); Ok(()) } @@ -4982,11 +4989,12 @@ pub fn strip_delay_carry_retained_bytes(moves: usize) -> Option { u64::try_from(moves.checked_mul(core::mem::size_of::())?).ok() } -/// The bytes a live-send section of `moves` routes retains in its plan (#1284): its move table. -/// `None` on overflow; spelled once, as [`strip_delay_carry_retained_bytes`]. +/// The bytes a live-send section of `moves` routes retains in its plan (#1284, #1652 D3): its move +/// table, each move with its first-block comparison slot. `None` on overflow; spelled once, as +/// [`strip_delay_carry_retained_bytes`]. #[must_use] pub fn route_carry_retained_bytes(moves: usize) -> Option { - u64::try_from(moves.checked_mul(core::mem::size_of::())?).ok() + u64::try_from(moves.checked_mul(core::mem::size_of::())?).ok() } /// The session timeline's sample at `source_read_sample` in a graph plan's source set (#1641 D8): @@ -5221,7 +5229,8 @@ struct GraphExecutorWithoutSplitPairTable { carry_delays: Box<[GraphDelayMove]>, compensation_lines: Vec, carry_strip_delays: Box<[GraphStripDelayMove]>, - carry_routes: Box<[GraphRouteMove]>, + carry_routes: Box<[runtime::RouteCarryStep]>, + route_carry_pending: bool, bind_identities: runtime::BindIdentities, live_snapshot: Option, jump_lengths: Option>, @@ -5348,6 +5357,7 @@ impl GraphExecutor { compensation_lines, carry_strip_delays: Box::default(), carry_routes: Box::default(), + route_carry_pending: false, bind_identities, live_snapshot: None, jump_lengths: None, @@ -5465,8 +5475,9 @@ impl PreparedPlanExecutor for GraphExecutor { .carry_delay_lines(&mut predecessor.runtime, &self.carry_delays); self.runtime .carry_strip_delays(&mut predecessor.runtime, &self.carry_strip_delays); - self.runtime - .carry_live_routes(&mut predecessor.runtime, &self.carry_routes); + self.route_carry_pending = self + .runtime + .carry_live_routes(&mut predecessor.runtime, &mut self.carry_routes); let refused = self.runtime.carry_effect_lanes( &mut predecessor.runtime, &self.carry_effects, @@ -5590,7 +5601,8 @@ impl PreparedPlanExecutor for GraphExecutor { carry_delays: _, compensation_lines: _, carry_strip_delays: _, - carry_routes: _, + carry_routes, + route_carry_pending, bind_identities: _, live_snapshot, jump_lengths, @@ -5664,6 +5676,11 @@ impl PreparedPlanExecutor for GraphExecutor { .as_ref() .map(|set| &*set.driver as &dyn GraphPreparedSourceSetDriver), ); + // #1652 D3: a six-word successor of a following predecessor compares at its first block, + // under this block's snapshot and jump lengths, before its op runs. + if core::mem::take(route_carry_pending) { + runtime.finish_route_carry(carry_routes, live, block_time.jump_lengths()); + } // 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"))] diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index 4103023d0..e931becd8 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -983,6 +983,27 @@ impl LiveRoute { self.position = from.position; self.mute = from.mute; } + + /// Take a following predecessor's ramp state (#1652 D3, the curve removed): the ramp, its + /// position and its mute, as [`Self::adopt`] takes a six-word one's. + fn adopt_state(&mut self, ramp: IndexedRamp, position: u32, mute: bool) { + self.ramp = ramp; + self.position = position; + self.mute = mute; + } + + /// #1652 D3: at the first block after a following predecessor's ramp was adopted, retarget to + /// this plan's own prepared target `prepared` (gate `mute`) from the coefficients the route is + /// at over `length`, only when the bits differ. A send the removed curve held muted reopens + /// over the session mute length, as the curve's own jump would have. + fn retarget_carried(&mut self, prepared: [f32; 4], mute: bool, length: u32) { + self.mute = mute; + if prepared.map(f32::to_bits) == self.ramp.target.map(f32::to_bits) { + return; + } + self.ramp = IndexedRamp::new(self.ramp.coefficients_at(self.position), prepared, length); + self.position = 0; + } } // REALTIME_POLICY_END @@ -1035,9 +1056,23 @@ struct FollowCursors { /// `a(tap)`. arrival: u64, mute_jump: crate::GraphMuteJumpLength, - /// The route has rendered no block yet: its first block sets the curve exactly at its first - /// sample when the curve there differs from the seed (#1647 D2, as the strip's fresh lane). - fresh: bool, + /// How the route's next block begins: fresh, carried or running. + start: FollowStart, +} + +/// How a following route's next block begins (#1652b, #1652 D3). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FollowStart { + /// The route of a fresh plan has rendered no block: its first block sets the curve exactly at + /// its first sample when the curve there differs from the seed (#1647 D2, as the strip's + /// fresh lane). + Fresh, + /// The route took a predecessor's ramp at a plan swap (D3): its first block places its + /// cursors at its own node time with the curve's value there (no `Set`), and then compares its + /// composed target with the adopted ramp's. + Carried, + /// Every later block. + Running, } /// One block's node spans per source lane and the block's mute jump length (#1652b). A lane with @@ -1086,7 +1121,7 @@ impl LiveFollowRoute { curve, arrival: source.arrival, mute_jump: source.mute_jump, - fresh: true, + start: FollowStart::Fresh, }; let (target, mute) = follow.compose(&transform, source.flags, curve); Self { @@ -1122,15 +1157,26 @@ impl FollowCursors { } /// The block's spans at the tap's node time (`node_span(a(tap))`, R17-3) and its mute jump - /// length (the host-core rule over the block's lengths). Takes the fresh flag: the first block - /// with no seek reaching it lands a `Set` at offset 0 on a lane whose curve there differs from - /// what the lane holds. - fn block(&mut self, time: &crate::GraphBlockTime<'_>) -> FollowBlock { - let fresh = core::mem::replace(&mut self.fresh, false); + /// length (the host-core rule over the block's lengths, whenever the block has them). Takes + /// the start: the first block of a fresh route with no seek reaching it lands a `Set` at + /// offset 0 on a lane whose curve there differs from what the lane holds; the first block of a + /// carried route instead seeds each lane with the curve's value at its first sample (D3: the + /// successor places its cursors fresh, and its block entry compares). Returns whether the + /// block is a carried route's first. + fn block(&mut self, time: &crate::GraphBlockTime<'_>) -> (FollowBlock, bool) { + let start = core::mem::replace(&mut self.start, FollowStart::Running); + let carried = start == FollowStart::Carried; let frames = time.frames(); - let (Some(span), Some(lengths)) = (time.node_span(self.arrival), time.jump_lengths()) - else { - return FollowBlock::IDLE; + let Some(lengths) = time.jump_lengths() else { + return (FollowBlock::IDLE, carried); + }; + let jump = core::cmp::min((self.mute_jump)(lengths), crate::ROUTE_RAMP_LENGTH_MAXIMUM); + let idle = FollowBlock { + spans: [None; 2], + jump, + }; + let Some(span) = time.node_span(self.arrival) else { + return (idle, carried); }; let length = i64::from(frames); if frames == 0 @@ -1139,27 +1185,39 @@ impl FollowCursors { .step .is_some_and(|step| step.timeline.checked_add(length).is_none()) { - return FollowBlock::IDLE; + return (idle, carried); } - let jump = core::cmp::min((self.mute_jump)(lengths), crate::ROUTE_RAMP_LENGTH_MAXIMUM); let mut spans = [None; 2]; - for ((slot, cell), state) in spans.iter_mut().zip(self.cells).zip(&self.states) { + for (((slot, cell), state), curve) in spans + .iter_mut() + .zip(self.cells) + .zip(&mut self.states) + .zip(&mut self.curve) + { let Some(program) = cell.and_then(|cell| self.program.cell(cell as usize)) else { continue; }; + if carried { + let value = automation::value_at(program.segments(), span.start); + state.seed_target(value); + *curve = value != 0.0; + } let step = span .step .map(|step| (step.offset, step.timeline)) .or_else(|| { + if start != FollowStart::Fresh { + return None; + } let value = automation::value_at(program.segments(), span.start); - (fresh && state.target().map(f32::to_bits) != Some(value.to_bits())) + (state.target().map(f32::to_bits) != Some(value.to_bits())) .then_some((0, span.start)) }); // The graph's step lies inside the block, so the span is valid; a refused one leaves // the lane holding its curve, with no panic. *slot = automation::NodeSpan::new(span.start, step, frames).ok(); } - FollowBlock { spans, jump } + (FollowBlock { spans, jump }, carried) } } @@ -1227,10 +1285,50 @@ fn follow_walk( } impl LiveFollowRoute { - /// The route op's block entry (#1652 D2): read the cell under the block's snapshot and, on a - /// target whose bits change, retarget from the coefficients the route is at over the - /// record's ramp; take the block's spans at the tap's node time; decide the block's - /// activity. Returns whether the route mixes this block. + /// Read the cell under `snapshot` and, on a target whose bits change, retarget from the + /// coefficients the route is at over the record's ramp (#1652 D2). The record composes with + /// the curve the route has heard. Returns how many values were applied: `0` or `1`. + #[inline] + fn apply_cell(&mut self, snapshot: LiveSnapshot) -> usize { + let mut applied = 0; + if let Some(record) = self.control.read(snapshot) { + self.transform = record.transform(); + self.flags = record.flags(); + let (target, mute) = + self.follow + .compose(&self.transform, self.flags, self.follow.curve); + self.mute = mute; + if target.map(f32::to_bits) != self.ramp.target.map(f32::to_bits) { + self.ramp = IndexedRamp::new( + self.ramp.coefficients_at(self.position), + target, + record.length(), + ); + self.position = 0; + } + applied = 1; + } + #[cfg(any(test, feature = "test-support"))] + test_only_count_route_drained(self.route as usize, applied, snapshot); + applied + } + + /// Take the ramp state of the predecessor's live route of the same ID, of either kind + /// (#1652 D3): the ramp, its position and its mute. The cursors do not carry: this plan's + /// own, unplaced, are placed at its first block's node time, which then compares. + fn adopt(&mut self, ramp: IndexedRamp, position: u32, mute: bool) { + self.ramp = ramp; + self.position = position; + self.mute = mute; + self.follow.start = FollowStart::Carried; + } + + /// The route op's block entry (#1652 D2): take the block's spans at the tap's node time; read + /// the cell under the block's snapshot and, on a target whose bits change, retarget from the + /// coefficients the route is at over the record's ramp; on a carried route's first block + /// (D3), compare the composed target with the adopted ramp's and, on a bits change, retarget + /// over the session mute length; decide the block's activity. Returns whether the route + /// mixes this block. /// /// The cell composes with the curve the route has heard (the curve at the block's first /// sample before that sample's events), and the block's curve events follow in offset order, @@ -1241,10 +1339,15 @@ impl LiveFollowRoute { /// event of it retargets, since every silencing target is `[+0.0; 4]`). #[inline] fn begin_block(&mut self, snapshot: LiveSnapshot, time: &crate::GraphBlockTime<'_>) -> bool { - let mut applied = 0; - if let Some(record) = self.control.read(snapshot) { - self.transform = record.transform(); - self.flags = record.flags(); + // The spans first: a carried route's first block places its curve at its own node time + // before anything composes with it. A running or fresh route's curve is unchanged by it. + let (block, carried) = self.follow.block(time); + self.block = block; + self.apply_cell(snapshot); + if carried { + // #1652 D3: after the join's record (if any, through the cell), the successor + // compares its own composed target with the adopted ramp's and retargets over the + // session mute length only on a bits change: the curve's own jump. let (target, mute) = self.follow .compose(&self.transform, self.flags, self.follow.curve); @@ -1253,16 +1356,11 @@ impl LiveFollowRoute { self.ramp = IndexedRamp::new( self.ramp.coefficients_at(self.position), target, - record.length(), + self.block.jump, ); self.position = 0; } - applied = 1; } - #[cfg(any(test, feature = "test-support"))] - test_only_count_route_drained(self.route as usize, applied, snapshot); - let _ = applied; - self.block = self.follow.block(time); if !(self.mute && self.position >= self.ramp.length && !self.delayed) { return true; } @@ -5121,29 +5219,159 @@ fn effect_node_mut( } } -/// The live route whose op sits in runtime unit `unit` (#1284 D2). Allocation-free: a -/// bounds-checked unit borrow and a match. -fn live_route_of(units: &[RuntimeUnit], unit: u32) -> Option<&LiveRoute> { - match units.get(unit as usize)? { - RuntimeUnit::Op(op) => match &op.kind { - NodeKind::LiveRoute(live) => Some(live), - _ => None, - }, - RuntimeUnit::Bank { .. } => None, +/// Whether runtime unit `unit` holds a live route's op of either kind (#1284 D2, #1652 D3): a +/// six-word [`LiveRoute`] or a following [`LiveFollowRoute`]. Allocation-free: a bounds-checked +/// unit borrow and a match. +fn is_live_route_unit(units: &[RuntimeUnit], unit: u32) -> bool { + match units.get(unit as usize) { + Some(RuntimeUnit::Op(op)) => { + matches!( + op.kind, + NodeKind::LiveRoute(_) | NodeKind::LiveFollowRoute(_) + ) + } + Some(RuntimeUnit::Bank { .. }) | None => false, } } -/// [`live_route_of`], mutably. -fn live_route_mut(units: &mut [RuntimeUnit], unit: u32) -> Option<&mut LiveRoute> { +/// One live route's render state, of either kind, borrowed for a plan swap's carry (#1652 D3). +enum CarriedRoute<'a> { + Six(&'a mut LiveRoute), + Seven(&'a mut LiveFollowRoute), +} + +/// The live route whose op sits in runtime unit `unit`, of either kind (#1284 D2, #1652 D3). +/// Allocation-free: a bounds-checked unit borrow and a match. +fn carried_route_mut(units: &mut [RuntimeUnit], unit: u32) -> Option> { match units.get_mut(unit as usize)? { RuntimeUnit::Op(op) => match &mut op.kind { - NodeKind::LiveRoute(live) => Some(live), + NodeKind::LiveRoute(live) => Some(CarriedRoute::Six(live)), + NodeKind::LiveFollowRoute(live) => Some(CarriedRoute::Seven(live)), _ => None, }, RuntimeUnit::Bank { .. } => None, } } +/// The six-word live route whose op sits in runtime unit `unit` (#1652 D3). Allocation-free. +fn live_route_mut(units: &mut [RuntimeUnit], unit: u32) -> Option<&mut LiveRoute> { + match carried_route_mut(units, unit)? { + CarriedRoute::Six(live) => Some(live), + CarriedRoute::Seven(_) => None, + } +} + +/// One move of a plan's live-send carry section (#1284 D2) and the comparison it leaves for the +/// successor's first block (#1652 D3). +#[derive(Clone, Copy)] +pub(crate) struct RouteCarryStep { + pub(crate) step: crate::GraphRouteMove, + /// Set at the swap block when a six-word successor took the ramp of a following predecessor + /// whose target's bits differ from the successor's own prepared target; taken by + /// [`GraphRuntime::finish_route_carry`] at the successor's first block. + check: Option, +} + +impl RouteCarryStep { + /// A move with no comparison pending. Control side. + pub(crate) const fn new(step: crate::GraphRouteMove) -> Self { + Self { step, check: None } + } +} + +/// What a six-word successor of a following predecessor compares at its first block (#1652 D3, +/// the curve removed): its own prepared target and mute, and the mute row's jump-length rule the +/// predecessor's source carried, which a six-word route does not hold. +#[derive(Clone, Copy)] +struct SixWordCarryCheck { + prepared: [f32; 4], + mute: bool, + mute_jump: crate::GraphMuteJumpLength, +} + +/// One six-word successor's comparison at its first block (#1652 D3, the curve removed): its cell +/// first, under the block's snapshot (the join's record, if any, is this plan's own value and +/// governs); without a record, [`LiveRoute::retarget_carried`] to its prepared target over the +/// session mute length. Allocation-, lock- and panic-free. +fn finish_six_word_carry( + live: &mut LiveRoute, + check: SixWordCarryCheck, + snapshot: LiveSnapshot, + lengths: Option, +) { + if live.apply_pending(snapshot) != 0 { + return; + } + let length = lengths.map_or(0, |lengths| { + core::cmp::min((check.mute_jump)(lengths), crate::ROUTE_RAMP_LENGTH_MAXIMUM) + }); + live.retarget_carried(check.prepared, check.mute, length); +} + +/// One live send's carry at the swap block (#1284 D2, #1652 D3), whatever the kind on either side. +/// The predecessor's pending value is applied under every snapshot first, as its next block +/// would apply it; then the successor takes the ramp, its position and the mute. +/// +/// * six -> six: [`LiveRoute::adopt`], exactly as before #1652. +/// * either -> seven: [`LiveFollowRoute::adopt`]; its first block places its own cursors at its +/// own node time and compares ([`LiveFollowRoute::begin_block`]). +/// * seven -> six: [`LiveRoute::adopt_state`]; when the adopted target's bits differ from the +/// successor's prepared target, the comparison is left in `check` for the successor's first +/// block, which knows the block's jump lengths. Returns whether it left one. +/// +/// Allocation-, lock- and panic-free: bounded copies. +fn carry_route( + into: CarriedRoute<'_>, + from: CarriedRoute<'_>, + check: &mut Option, +) -> bool { + *check = None; + let (ramp, position, mute, mute_jump) = match from { + CarriedRoute::Six(from) => { + from.apply_pending(LiveSnapshot::ALL); + if let CarriedRoute::Six(into) = into { + into.adopt(from); + return false; + } + (from.ramp, from.position, from.mute, None) + } + CarriedRoute::Seven(from) => { + from.apply_cell(LiveSnapshot::ALL); + ( + from.ramp, + from.position, + from.mute, + Some(from.follow.mute_jump), + ) + } + }; + match into { + CarriedRoute::Seven(into) => { + into.adopt(ramp, position, mute); + false + } + CarriedRoute::Six(into) => { + // A six-word successor has rendered no block at the swap: its ramp is the settled + // prepared target bind gave it, and its mute is the prepared gate's. + let (prepared, prepared_mute) = (into.ramp.target, into.mute); + into.adopt_state(ramp, position, mute); + let Some(mute_jump) = mute_jump else { + return false; + }; + if prepared.map(f32::to_bits) == ramp.target.map(f32::to_bits) { + into.mute = prepared_mute; + return false; + } + *check = Some(SixWordCarryCheck { + prepared, + mute: prepared_mute, + mute_jump, + }); + true + } + } +} + /// The observer binding at `at` (#1327 D2): a bounds-checked unit, member and index lookup. /// Allocation-free. `None` when `at` names no observer, including a member of a unit that is one /// op and no member of a bank. @@ -5609,9 +5837,10 @@ impl crate::runtime::Runtime { } } - /// Whether the unit `unit` of this runtime is a live route's op (#1284 D2). + /// Whether the unit `unit` of this runtime is a live route's op of either kind (#1284 D2, + /// #1652 D3). pub(crate) fn is_live_route(&self, unit: u32) -> bool { - live_route_of(&self.units, unit).is_some() + is_live_route_unit(&self.units, unit) } /// Whether every move of `moves` names a strip delay line of this (successor) runtime and one @@ -5654,40 +5883,65 @@ impl crate::runtime::Runtime { } /// Whether every move of `moves` names a live route unit of this (successor) runtime and one - /// of `predecessor` (#1284 D2). Checked before anything moves, so the carry is all or nothing. - /// Allocation-free. + /// of `predecessor`, of either kind on either side (#1284 D2, #1652 D3). Checked before + /// anything moves, so the carry is all or nothing. Allocation-free. pub(crate) fn can_carry_live_routes( &self, predecessor: &Self, - moves: &[crate::GraphRouteMove], + moves: &[RouteCarryStep], ) -> bool { moves.iter().all(|step| { - live_route_of(&self.units, step.successor).is_some() - && live_route_of(&predecessor.units, step.predecessor).is_some() + is_live_route_unit(&self.units, step.step.successor) + && is_live_route_unit(&predecessor.units, step.step.predecessor) }) } - /// Run the live-send section of a carry program at the swap block (#1284 D2): the - /// predecessor's pending value is applied under every snapshot, as its next block would apply - /// it ([`LiveRoute::apply_pending`]), and then the successor takes its ramp, position and mute - /// ([`LiveRoute::adopt`]). Bounded by `moves.len()`; allocation-free and lock-free. The - /// caller has checked [`Self::can_carry_live_routes`]. + /// Run the live-send section of a carry program at the swap block (#1284 D2, #1652 D3): for + /// each move, [`carry_route`], whatever the route's kind in either plan; a lane gained or lost + /// for a following route is not a change of the route's structure. Returns whether some move + /// left a six-word successor a comparison for its first block + /// ([`Self::finish_route_carry`]). Bounded by `moves.len()`; allocation-free and lock-free. + /// The caller has checked [`Self::can_carry_live_routes`]. pub(crate) fn carry_live_routes( &mut self, predecessor: &mut Self, - moves: &[crate::GraphRouteMove], - ) { - for step in moves.iter() { + moves: &mut [RouteCarryStep], + ) -> bool { + let mut pending = false; + for step in moves.iter_mut() { let (Some(into), Some(from)) = ( - live_route_mut(&mut self.units, step.successor), - live_route_mut(&mut predecessor.units, step.predecessor), + carried_route_mut(&mut self.units, step.step.successor), + carried_route_mut(&mut predecessor.units, step.step.predecessor), ) else { continue; }; // Admission validated the cell's words; the count of values this applies is the // drain's own, added to the session's `live_values_superseded` as at a block. - from.apply_pending(LiveSnapshot::ALL); - into.adopt(from); + pending |= carry_route(into, from, &mut step.check); + } + pending + } + + /// The successor's first block after a carry that left comparisons (#1652 D3, the curve + /// removed): each six-word route that took a following predecessor's ramp first applies its + /// own cell under the block's snapshot (the join's record, if any, through the cell, as its + /// block entry would); without one, it compares its prepared target with the adopted ramp's + /// and, on a bits change, retargets from the coefficients it is at over the session mute + /// length (`mute_jump` of the block's `lengths`, `0` with none). Each comparison is taken + /// once. Bounded by `moves.len()`; allocation-, lock- and panic-free. + pub(crate) fn finish_route_carry( + &mut self, + moves: &mut [RouteCarryStep], + snapshot: LiveSnapshot, + lengths: Option, + ) { + for step in moves.iter_mut() { + let Some(check) = step.check.take() else { + continue; + }; + if let Some(live) = live_route_mut(&mut self.units, step.step.successor) { + finish_six_word_carry(live, check, snapshot, lengths); + } } } @@ -7942,9 +8196,9 @@ fn strip_delay_rows( .collect() } -/// The live routes of a bind, in unit order (#1284 D2): the route's session ID and its runtime -/// unit. `route_gates` are the prepared routes in canonical route-ID order, which a live route's -/// index points into. +/// The live routes of a bind, of either kind, in unit order (#1284 D2, #1652 D3): the route's +/// session ID and its runtime unit. `route_gates` are the prepared routes in canonical route-ID +/// order, which a live route's index points into. fn live_route_rows( units: &[RuntimeUnit], route_gates: &[(GraphNodeId, RouteGate)], @@ -7956,10 +8210,14 @@ fn live_route_rows( let RuntimeUnit::Op(op) = owner else { return None; }; - let NodeKind::LiveRoute(live) = &op.kind else { - return None; + // #1652 D3: a following route is listed as a six-word one is, so the successor's + // join carries it by route ID whatever its kind in either plan. + let route = match &op.kind { + NodeKind::LiveRoute(live) => live.route, + NodeKind::LiveFollowRoute(live) => live.route, + _ => return None, }; - let GraphNodeId::Route { route_id } = &route_gates.get(live.route as usize)?.0 else { + let GraphNodeId::Route { route_id } = &route_gates.get(route as usize)?.0 else { return None; }; Some(crate::GraphLiveRoute { @@ -17291,4 +17549,253 @@ mod follow_route_op_tests { let both = Fixture::new([Some(&on), Some(&on)], 0, OPEN); assert!(both.route.mute); } + + /// #1652 D3: the carry of a live send across a plan swap, whatever its kind in either plan. + /// Each case drives the swap block at the route level: `carry_route` (what + /// `carry_live_routes` runs per move), then the successor's first block (with + /// `finish_six_word_carry` first for a six-word successor, as render runs it before the + /// units), against a never-swapped twin or the ramp the swap must start. + mod carry { + use super::super::{CarriedRoute, LiveRoute, carry_route, finish_six_word_carry}; + use super::{ + FIRST, Fixture, FrameLane, IndexedRamp, MOVED, MUTE, OPEN, RECORD_RAMP, bits, gated, + input, route_mix_ramp_block, + }; + use crate::follow_route_tests::steps; + use crate::{GraphJumpLengths, GraphRouteControlProducer, RouteControlRecord}; + use engine::realtime::{GatePin, LiveValuesSuperseded, RevisionGate}; + + /// The block lengths [`Fixture::block`] renders with. + const LENGTHS: GraphJumpLengths = GraphJumpLengths { + fader_samples: 480, + mute_samples: MUTE, + pan_samples: 960, + }; + /// Frames the predecessor renders before the swap: the swap lands mid-ramp. + const BEFORE: usize = 100; + /// Frames of the swap block. + const AFTER: usize = 128; + + /// A six-word live route with its own cell, gate and pin. + struct Six { + route: Box, + producer: GraphRouteControlProducer, + gate: RevisionGate, + pin: GatePin, + revision: u64, + } + + impl Six { + /// A route bound on the settled `prepared` target with its gate's `mute`. + fn new(prepared: [f32; 4], mute: bool) -> Self { + let lane = std::sync::Arc::new(crate::RouteLane::new()); + let control = Box::new(crate::RouteControlLane { + lane: std::sync::Arc::clone(&lane), + reader: engine::realtime::CellReader::new(), + dirty: engine::realtime::DirtyReader::new(), + superseded: LiveValuesSuperseded::new(), + }); + let producer = GraphRouteControlProducer { + route_id: "send".into(), + lane, + writer: engine::realtime::CellWriter::new(), + activity_bytes: 0, + activity_largest: 0, + }; + let (gate, pin) = RevisionGate::pair(0); + Self { + route: Box::new(LiveRoute::new(prepared, mute, control)), + producer, + gate, + pin, + revision: 0, + } + } + + fn write(&mut self, target: [f32; 4], length: u32) { + self.revision += 1; + let record = RouteControlRecord::new(target, false, length).expect("record"); + self.producer + .write(self.pin.stamp(self.revision).expect("stamp"), record); + self.pin.publish(&self.gate, self.revision); + } + + /// The six-word route op's block: the drain, the activity, the mix. + fn block(&mut self, left: &mut [f32], right: &mut [f32]) -> bool { + self.route.apply_pending(self.gate.snapshot()); + let active = self.route.active(); + if active { + self.route.mix(left, right); + } + active + } + } + + /// A predecessor six-word route mid-ramp: from the open `OPEN` coefficients toward the + /// open `MOVED` ones over `RECORD_RAMP`, `BEFORE` frames in. + fn six_mid_ramp() -> Six { + let mut route = Six::new(gated(OPEN, [false; 2]), false); + route.write(gated(MOVED, [false; 2]), RECORD_RAMP); + let (mut left, mut right) = input(BEFORE); + assert!(route.block(&mut left, &mut right)); + route + } + + /// A predecessor following route mid-ramp, as [`six_mid_ramp`], over an open curve. + fn seven_mid_ramp(open: &[automation::Segment]) -> Fixture { + let mut route = Fixture::new([Some(open), None], 0, OPEN); + route.write(MOVED, RECORD_RAMP); + let (mut left, mut right) = input(BEFORE); + assert!(route.block(FIRST, None, &mut left, &mut right)); + route + } + + /// The twin's swap block: the same route, never swapped, `AFTER` more frames. + fn twin_block(ramp: IndexedRamp) -> Vec { + let (mut left, mut right) = input(AFTER); + route_mix_ramp_block::(&mut left, &mut right, &ramp, BEFORE as u32); + bits((&left, &right)) + } + + /// Gate 4 (six -> seven, the rebuild adds an automated mute to the source). (a) With the + /// curve open at the swap, the successor's composed target has the adopted ramp's bits: + /// it keeps the ramp, its position and its mute by route ID and its swap block equals the + /// never-swapped twin's, bit for bit. (b) With the curve on at the swap (it stepped on + /// before the swap, so its first value is off), the successor places its cursor at its + /// own node time and retargets from the coefficients it is at over the session mute + /// length: not from rest, not as an exact `Set`. + /// + /// Test value: red if a following successor is not carried (it starts settled on its own + /// target), if the carried first block retargets on equal bits (position reset), if its + /// cursor is seeded with the curve's first value instead of its value at the node time, + /// or if the carried block lands the fresh route's `Set` instead of the mute length. + #[test] + fn six_to_seven_keeps_the_ramp_state_by_route_id() { + let open = steps(&[0], &[0.0], 1 << 20); + let mut predecessor = six_mid_ramp(); + let ramp = predecessor.route.ramp; + assert_eq!(predecessor.route.position, BEFORE as u32); + let expected = twin_block(ramp); + + let mut successor = Fixture::new([Some(&open), None], 0, MOVED); + let (mut left, mut right) = input(AFTER); + let mut check = None; + let pending = carry_route( + CarriedRoute::Seven(&mut successor.route), + CarriedRoute::Six(&mut predecessor.route), + &mut check, + ); + assert!(successor.block(FIRST + BEFORE as u64, None, &mut left, &mut right)); + assert!(!pending && check.is_none()); + assert_eq!(bits((&left, &right)), expected, "(a) a pure re-adoption"); + assert_eq!(successor.route.ramp, ramp, "(a) the ramp is kept"); + assert_eq!(successor.route.position, (BEFORE + AFTER) as u32); + + // (b) The curve stepped on at timeline 500, before the swap block at 1100. + let on = steps(&[0, 500], &[0.0, 1.0], 1 << 20); + let mut predecessor = six_mid_ramp(); + let at = predecessor.route.ramp.coefficients_at(BEFORE as u32); + let mut successor = Fixture::new([Some(&on), None], 0, MOVED); + carry_route( + CarriedRoute::Seven(&mut successor.route), + CarriedRoute::Six(&mut predecessor.route), + &mut None, + ); + let (mut left, mut right) = input(AFTER); + assert!(successor.block(FIRST + BEFORE as u64, None, &mut left, &mut right)); + let reopened = IndexedRamp::new(at, gated(MOVED, [true, false]), MUTE); + let (mut expected_left, mut expected_right) = input(AFTER); + route_mix_ramp_block::( + &mut expected_left, + &mut expected_right, + &reopened, + 0, + ); + assert_eq!( + bits((&left, &right)), + bits((&expected_left, &expected_right)), + "(b) the curve's own jump from where the route is" + ); + assert_eq!(successor.route.ramp, reopened); + } + + /// Gate 4 (seven -> seven): a following route mid-ramp keeps its ramp and position into a + /// following successor whose composed target has the same bits, and the swap block equals + /// the never-swapped twin's, bit for bit. + /// + /// Test value: red if the seven-word kind is left out of the carry (the successor starts + /// settled), or if the first block's comparison retargets on equal bits. + #[test] + fn seven_to_seven_mid_ramp_keeps_its_position() { + let open = steps(&[0], &[0.0], 1 << 20); + let mut predecessor = seven_mid_ramp(&open); + let ramp = predecessor.route.ramp; + assert_eq!(predecessor.route.position, BEFORE as u32); + let expected = twin_block(ramp); + + let mut successor = Fixture::new([Some(&open), None], 0, MOVED); + let (mut left, mut right) = input(AFTER); + let pending = carry_route( + CarriedRoute::Seven(&mut successor.route), + CarriedRoute::Seven(&mut predecessor.route), + &mut None, + ); + assert!(successor.block(FIRST + BEFORE as u64, None, &mut left, &mut right)); + assert!(!pending); + assert_eq!(bits((&left, &right)), expected); + assert_eq!(successor.route.ramp, ramp); + assert_eq!(successor.route.position, (BEFORE + AFTER) as u32); + } + + /// Gate 4 (b) (seven -> six, the rebuild removes the mute entry): a following route the + /// curve holds muted (settled and inactive) is carried into a six-word successor whose + /// prepared target is open. The successor's first block reopens the send from silence + /// over the session mute length. + /// + /// Test value: red if the six-word successor keeps the adopted mute (the comparison is + /// missing: the send stays muted for good, since the model diff cannot see the curve), or + /// if it reopens with a step instead of the mute length. + #[test] + fn seven_to_six_reopens_a_send_the_removed_curve_held_muted() { + let on = steps(&[0], &[1.0], 1 << 20); + let mut predecessor = Fixture::new([Some(&on), Some(&on)], 0, OPEN); + let (mut left, mut right) = input(BEFORE); + assert!(!predecessor.block(FIRST, None, &mut left, &mut right)); + assert!(predecessor.route.mute); + + let prepared = gated(OPEN, [false; 2]); + let mut successor = Six::new(prepared, false); + let (mut left, mut right) = input(AFTER); + let mut check = None; + let pending = carry_route( + CarriedRoute::Six(&mut successor.route), + CarriedRoute::Seven(&mut predecessor.route), + &mut check, + ); + if let Some(check) = check { + finish_six_word_carry( + &mut successor.route, + check, + successor.gate.snapshot(), + Some(LENGTHS), + ); + } + let active = successor.block(&mut left, &mut right); + assert!(pending, "a comparison is left for the first block"); + assert!(active, "the reopened send mixes its swap block"); + let reopened = IndexedRamp::new([0.0; 4], prepared, MUTE); + let (mut expected_left, mut expected_right) = input(AFTER); + route_mix_ramp_block::( + &mut expected_left, + &mut expected_right, + &reopened, + 0, + ); + assert_eq!( + bits((&left, &right)), + bits((&expected_left, &expected_right)) + ); + assert!(!successor.route.mute); + } + } } From 9a3eb4b5a72bd421bcc6628d64c3dcdb4ceaf362 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 07:43:22 +0000 Subject: [PATCH 07/23] Add the owed-discharge gap to #1711 (root ruling, batch 18) A free live fader write discharges the owed move (self.owed = false), and no test turns red when that line is deleted. #1711 reworks the same owed and hold machinery, so it now owns proving the discharge observable and gating it, or proving it unobservable and deleting it. Co-Authored-By: Claude Opus 5.5 --- ...ve-level-moves-own-length-across-a-reposition-swap.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/ISSUE_SPECS/1711-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 index 10623fc97..d5147a73a 100644 --- a/.github/ISSUE_SPECS/1711-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 @@ -98,3 +98,12 @@ conservative fallback flag of B17-R14 with the exact behaviour and deletes that ## Dependencies - #1648 (closed on its gates), B17-R13 and B17-R14 (the conservative flag this issue removes). + +## Amendment (controls coordinator, batch 18, root ruling 2026-10-17) + +- **Added item: gate or delete the owed discharge on a free fader write.** In + `crates/builtins-compiler/src/lib.rs` (about `:1689` on `ad03780ee`), a free live write releases the + hold and sets `self.owed = false`. Deleting that line leaves every test green (batch 18 T1 gate + power check, `scratchpad/c18t1/`). Prove the discharge observable and gate it, or prove it + unobservable and delete it. This slice reworks the same owed and hold machinery at a Reposition, + so the item belongs here, not in a phase-2 placeholder. From 377fcd87f3d63166ae477ddc4805a0f3d19a5ed2 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 07:46:47 +0000 Subject: [PATCH 08/23] Render stored mute automation on the strip lane (#1651 T3a) Row 6 renders. Mute cells go into the plan's one program after every fader cell (a fader-only session builds the same program and bits). The stage mute of an automated lane is curve || terms, terms being the effective strip mute (OQ1 = C); the drain routes the live Mute value to the lane slot's terms and composes at the block boundary. The one FaderEventDriver walks mute cursors and pushes a SetMute only when the composed mute changes, at its exact sample over the session mute length; a seek sets it exactly and a seek that leaves the mute unchanged lets the ramp finish (R18-1). The scalar oracle runs the same slot and driver. A mute entry change is a rebuild on both hosts; the superseded #1648 gate 1 mute asserts and the parity mute branch are rewritten. PreparedBuiltinsSession::strip_mute_cells exports the program and the mute cell indices for #1652. Lane slot 304 -> 392 B; realtime policy 161; pcm_digest unchanged. Co-Authored-By: Claude Opus 5.5 --- .../1651-render-stored-mute-automation.md | 89 +++ crates/builtins-compiler/src/lib.rs | 679 +++++++++++++++-- crates/controls/src/automation.rs | 64 +- crates/controls/src/builtin_rows.rs | 2 +- crates/controls/src/lib.rs | 8 +- crates/host-core/src/live_delta.rs | 16 +- crates/host-core/src/prepare.rs | 5 +- crates/host-core/tests/live_delta.rs | 23 +- .../host-core/tests/stored_mute_automation.rs | 693 ++++++++++++++++++ docs/REALTIME_MEMORY.md | 2 +- docs/SESSION_SCHEMA_V1.md | 12 +- hosts/host-web/tests/control_parity.rs | 6 +- 12 files changed, 1493 insertions(+), 106 deletions(-) create mode 100644 crates/host-core/tests/stored_mute_automation.rs diff --git a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md index 593043338..de0908aa2 100644 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md @@ -409,3 +409,92 @@ PR #1712). test-support`, `-p host-core --features host-core/test-support`, `-p capi`: all pass. fmt, clippy (`controls`, `control-plane`, all targets and features) and the no-feature check pass. - **Departures.** `automated_cell` is not changed (above). None else. + +## Decision record: T3a (batch 18) + +- **Shipped (row 6 renders).** `controls::automation_row_renders` is `fader_row || mute_row` (the + only reader that gains row 6; T2's fader-only readers are unchanged). New in `controls`: + `MUTE_CELL_RULE` (grid 64, `JumpLength::Block`, completion `End`, restartable, seek `Exact`; the + caller resolves `LiveRamps::for_row(Mute)`), `mute_row` and `mute_automation` (the + `fader_automation` rule on row 6). +- **Program.** `prepare_fader_automation` pushes every fader cell first (unchanged order), then + one mute cell per automated mute lane in strip and channel order. A fader-only session builds + the same program; a mute-only session builds mute cells only (the early return already reads + `automation_row_renders`). The prepared stage mute of an automated mute lane is + `first start_value != 0 || terms`, `terms` seeded with `EffectiveStrip::mute` + (`FaderAutomationPlan::mute`, read by `prepared_fader`). +- **Slot.** `LaneFaderAutomation` gains `mute: [Option; 2]` (cell index, + `CellState` seeded with the prepared curve value, `terms`) and `mute_jump`, both from + `set_block_time` (`block_jumps` replaces `block_fader_jump` and resolves both rows through + `LiveRamps`). A strip with mute automation and no fader automation gets a bank slot and, on the + scalar oracle, binds `AutomatedFaderProcessor` (R17-4); the bank resource charges its slots. +- **Drain branch.** `drain_fader_controls` (bank, block and carry drains) and the oracle's + `LiveControlFaderProcessor::drain_controls` give a mute value of an automated channel to + `LaneFaderAutomation::mute_terms` (shared helper `apply_mute_value`): it stores `terms`, composes + `target != 0 || terms` with the cell's current target and applies `SetMute` over the record's + ramp word only when that differs from the stage's mute. A non-automated channel of the same + record goes to the stage as before. The commit code does not change. +- **Driver.** `FaderEventDriver` holds one `MuteCellCursor` per automated mute channel beside the + fader cursors (same `a(n)`, same span, same first-block `Set` rule). At each offset the fader + cursors push first; then each mute event composes `curve || terms` and pushes `SetMute` only on + a change (jump: over the block's mute length; seek `Set`: `0`), into the sink's mute slot, so + the stage applies gain before mute. `finish` drains the mute cursors. A mute event in the block + makes the fused fader-matrix pair decline that block (its `next_offset` is in the block). +- **R18-1** holds through the 13b rule and the change-only compare: a seek `Set` that leaves + `curve || terms` equal to the stage's mute pushes nothing, so a ramp toward that state finishes. +- **Export for #1652.** `PreparedBuiltinsSession::strip_mute_cells(&self, track_id: &str) -> + Option<(Arc, [Option; 2])>`: the plan's one program (`Arc::ptr_eq` + with the fader owners') and each lane's mute cell index, `[left, right]`; `None` when the strip + automates no mute lane. Read it before lowering (`into_graph_artifact` takes the plan). +- **Docs.** `host-core/src/live_delta.rs` (step 3 and the `Mute` record paragraph), + `host-core/src/prepare.rs` (`LiveRebuild::Automation`), `docs/SESSION_SCHEMA_V1.md` (the mute + row renders), `docs/REALTIME_MEMORY.md`. +- **Superseded tests, rewritten in place.** `crates/host-core/tests/live_delta.rs` + (`a_fader_automation_edit_is_a_rebuild`: the three mute asserts expect + `Err(LiveRebuild::Automation)`) and `hosts/host-web/tests/control_parity.rs` + (`a_fader_automation_edit_rebuilds_alike_on_both_hosts`: the mute entry is `EditPath::Rebuild`). + `live_delta.rs` is gated on host-core's `control-provider` feature: run with + `--features host-core/test-support,host-core/control-provider` (70 pass). +- **Gates (all pass).** + - `crates/host-core/tests/stored_mute_automation.rs` (new; every form: Simd8 on AVX2, Simd4, + scalar oracle; each with no live control, live controls, and between render calls): + `a_mute_step_mutes_the_lane_at_its_exact_sample_over_the_mute_length` (steps 0, 1, 0 at 1,000 + and 2,500, quanta 128, 100 and 37: `SetMute` at exactly those samples over `M = 480`, output + bit for bit the stage driven directly; render allocates nothing); + `a_curve_that_starts_muted_prepares_the_lane_muted`; + `a_seek_that_leaves_the_mute_unchanged_lets_the_unmute_ramp_finish` (R18-1: bit-identical to + the no-seek twin; and a seek into the muted span sets the lane exactly, `n = 0`); + `a_hand_mute_on_an_automated_lane_composes_with_the_curve` (the drain branch, OQ1 = C); + `a_mute_entry_leaves_a_fader_ride_as_it_was`. + - `crates/builtins-compiler/src/lib.rs` + `tests::the_mute_cells_follow_the_fader_cells_in_the_one_program` (cell order, program bytes, + the export, a mute-only program). +- **Measured.** One-time PR evidence (not committed): a fader-only session (three tracks: a fade, + a step and fade, a step; live mutes, a seek; every form; quanta 128 and 100) renders the same + bits at base `e7848d05a` and head (FNV digest `a58534af7e8b0509` both). `audit capi` + `pcm_digest` `bffc21e857b5e134`, all violation counts 0 (`audit capi` reports no + `capi_retained` field). `fader_automation_sizes().lane_slot_bytes` 304 -> 392 (two mute + channels and the mute jump word); `program_overhead_bytes` 64 and `offsets_cell_bytes` 120 + unchanged. `realtime_policy`: ok, 161 marked regions (base 160: `apply_mute_value`). +- **Departures.** + - The slot stores no ramp word and no adoption for a mute channel: the drain uses the ramp word + at once and curve events use the block's mute length, so nothing reads them before T4's + carry; T4 adds what `adopt` needs. + - The defensive refusals of a mute entry use the code `host.automation.mute` (unreachable for a + validated session, as the fader's). + - `TestOnlyFaderEvent` gains `mute: bool` (the probe records each pushed `SetMute`). +- **What T3b must add.** A mute event inside a block renders correctly on a lane with no fader + automation (all gates above). On a lane with both, the in-block fader cut is missing: after an + in-block mute at `o` the fader cursor still pushes its later events as free (the stage only + remembers them, and no obligation is set), and after an in-block unmute over `M` the fader + events inside `[o, o + M)` are only remembered with no move owed at `E = o + M`, so the lane + can end the unmute ramp at a stale gain and the cell's committed target suppresses the + correction. T3b: at each pushed `SetMute` at `o` on a channel with a fader cursor, end that + channel's fader piece at `o`, start the next at `o + 1` with the hold the `SetMute` gives + (muted: held to the block's end; unmute over `M`: `E = o + M`), mark owed events below `E` + through the D1.6 obligation and cut at `E`; a gain operation at `o` keeps the old hold (gain + before mute). Gate 2 holds it. +- **For T4.** `LaneFaderAutomation::adopt` does not touch the mute channels yet (a carried lane + keeps its prepared mute seed and `terms`, is not fresh, and lands no `Set`); the carry drain + already routes an automated channel's mute value to the predecessor slot's `terms`, which + composes onto the predecessor's stage before export. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index db6adb3e2..4a50dfb80 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -827,13 +827,34 @@ struct HeldRampObligation { held_through: bool, } -/// 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. +/// One automated mute channel as a fader owner holds it (#1651 D1, batch 18 amendment): its cell +/// in the plan's program, its event state and `terms`. +/// +/// `terms` is the lane's mute without the curve (OQ1 = C: `controls::EffectiveStrip::mute`), so +/// the stage mute of the lane is `curve || terms`. Preparation seeds it; the strip's mute cell +/// (#1312) carries every later value of it, the `Mute` record the shared commit emits from the +/// effective mute, and the block drain gives that value to this cell +/// ([`LaneFaderAutomation::mute_terms`]) instead of to the stage. +#[derive(Clone, Copy, Debug)] +struct AutomatedMuteCell { + /// The cell's index in the plan's [`automation::Program`]. + cell: u32, + /// Seeded with the prepared curve value, so a fresh lane's first block emits nothing. + state: automation::CellState, + /// The mute terms the session does not automate. + terms: bool, +} + +/// One strip lane's stored fader and mute automation (#1646 D1, #1651 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. struct LaneFaderAutomation { program: Arc, - /// `[left, right]`; `None` for a channel no entry drives. + /// `[left, right]`; `None` for a channel no fader entry drives. channels: [Option; 2], + /// `[left, right]`; `None` for a channel no mute entry drives (#1651). + mute: [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, @@ -845,17 +866,32 @@ struct LaneFaderAutomation { /// D2); `None` in a plan with no jump-length cell. The event driver passes it to /// `CellEvents::new`. fader_jump: Option, + /// The mute row's jump length of that block (`LiveRamps::for_row(Mute)`, #1651 D1), from the + /// same three lengths; `None` exactly when [`Self::fader_jump`] is. + mute_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, } impl LaneFaderAutomation { - /// The lane's automation, from its prepared cells, sharing each channel's offsets cell. + /// The lane's automation, from its prepared cells, sharing each fader channel's offsets cell. /// Control side. - fn new(program: &Arc, cells: &[Option; 2]) -> Self { + fn new(program: &Arc, strip: &PreparedStripAutomation) -> Self { + let cells = &strip.fader; Self { program: Arc::clone(program), + mute: strip.mute.map(|cell| { + cell.map(|cell| { + let mut state = automation::CellState::new(); + state.seed_target(cell.value); + AutomatedMuteCell { + cell: cell.cell, + state, + terms: cell.terms, + } + }) + }), channels: core::array::from_fn(|channel| { cells[channel].as_ref().map(|cell| { let mut state = automation::CellState::new(); @@ -878,6 +914,7 @@ impl LaneFaderAutomation { arrival: 0, span: None, fader_jump: None, + mute_jump: None, fresh: true, } } @@ -961,11 +998,53 @@ impl LaneFaderAutomation { } } - /// 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) { + /// Take the block's node-time mapping and the fader and mute rows' jump lengths (#1647a, + /// R17-3; #1651 D1): 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<'_>, jumps: Option) { self.span = time.node_span(self.arrival); - self.fader_jump = fader_jump; + self.fader_jump = jumps.map(|jumps| jumps.fader); + self.mute_jump = jumps.map(|jumps| jumps.mute); + } + + /// #1651 D1, the drain branch: a mute value the block drain read for channel `channel`, whose + /// mute this lane automates, is the lane's new `terms` (the shared commit's `Mute` record + /// carries `controls::EffectiveStrip::mute`, OQ1 = C). The cell stores it and composes the + /// stage mute `curve || terms` with the curve state the stage has heard (the cell's current + /// target); when that differs from the stage's mute it applies `SetMute` at the block + /// boundary over the record's ramp word `ramp`, as [`Self::read_offsets`] applies its jump. + /// The block's curve events then compose with the new `terms`. A channel with no mute cell + /// is not this cell's (the caller applies its record to the stage). Allocation-, lock- and + /// syscall-free: one compare and at most one operation. + fn mute_terms( + &mut self, + channel: usize, + terms: bool, + ramp: u32, + stage: &mut impl FaderJumpStage, + ) { + let Some(cell) = self.mute.get_mut(channel).and_then(Option::as_mut) else { + return; + }; + cell.terms = terms; + let muted = cell.state.target().is_some_and(curve_mutes) || terms; + if stage.muted(channel) != muted { + let applied = stage.apply( + channel, + builtins::FaderOperation::SetMute { + muted, + samples: ramp, + }, + ); + // Unreachable refusal: the lane is a populated member and a mute operation carries + // no gain. In release the stage keeps its mute, no panic. + debug_assert!(applied, "a mute terms write is refused at render"); + } + } + + /// Whether this lane automates the mute of channel `channel`. + fn automates_mute(&self, channel: usize) -> bool { + self.mute.get(channel).is_some_and(Option::is_some) } /// #1649 D3, at block entry under the block's `snapshot`: each automated channel whose @@ -1057,6 +1136,8 @@ 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; + /// Whether `channel` is muted (settled, or in a ramp toward `0.0`). + fn muted(&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. @@ -1074,6 +1155,9 @@ impl FaderJumpStage for BankLaneStage<'_> { fn gain_held(&self, channel: usize) -> bool { self.bank.gain_held(self.lane, channel) == Some(true) } + fn muted(&self, channel: usize) -> bool { + self.bank.is_muted(self.lane, channel) == Some(true) + } fn ramp_remaining(&self, channel: usize) -> u32 { self.bank.ramp_remaining(self.lane, channel).unwrap_or(0) } @@ -1089,6 +1173,9 @@ impl FaderJumpStage for FaderMuteRampBuiltins { fn gain_held(&self, channel: usize) -> bool { Self::gain_held(self, channel) == Some(true) } + fn muted(&self, channel: usize) -> bool { + self.is_muted(channel) + } fn ramp_remaining(&self, channel: usize) -> u32 { Self::ramp_remaining(self, channel).unwrap_or(0) } @@ -1238,17 +1325,29 @@ fn span_time(start: i64, step: Option<(u32, i64)>, offset: u64) -> Option { 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 { +/// The fader and mute rows' jump lengths of one block (#1639 D2, #1651 D1). +#[derive(Clone, Copy, Debug)] +struct BlockJumps { + /// `LiveRamps::for_row(Fader)`. + fader: u32, + /// `LiveRamps::for_row(Mute)`. + mute: u32, +} + +/// The fader and mute rows' jump lengths of a block (#1639 D2, #1647a departure 7, #1651 D1), +/// 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_jumps(time: &GraphBlockTime<'_>) -> Option { time.jump_lengths().map(|lengths| { - controls::LiveRamps::from_lengths( + let ramps = controls::LiveRamps::from_lengths( lengths.fader_samples, lengths.mute_samples, lengths.pan_samples, - ) - .for_row(effect_contract::control::LiveRampRow::Fader) + ); + BlockJumps { + fader: ramps.for_row(effect_contract::control::LiveRampRow::Fader), + mute: ramps.for_row(effect_contract::control::LiveRampRow::Mute), + } }) } @@ -1398,10 +1497,36 @@ impl FaderCellCursor<'_> { /// `debug_assert`. Fixed size, on the render stack; allocation-, lock- and syscall-free. struct FaderEventDriver<'a> { cursors: [Option>; MAXIMUM_FADER_CELLS], + /// The automated mute channels' cursors (#1651 D1), one per lane and channel. + mutes: [Option>; MAXIMUM_FADER_CELLS], #[cfg(any(test, feature = "test-support"))] first_sample: u64, } +/// One automated mute channel's events of one block (#1651 D1): its cell's jumps and seek +/// landings, and the stage mute the driver last composed. +struct MuteCellCursor<'a> { + /// The owner's lane (the bank lane; `0` on the scalar oracle). + lane: usize, + /// `0` left, `1` right. + channel: usize, + /// The lane's `terms` (OQ1 = C), as the block's drain left it. + terms: bool, + /// The stage mute: the stage's at the block's first sample (after the drain), then each + /// composition the driver pushed. + muted: bool, + events: automation::CellEvents<'a, 'a>, + /// The next event, its offset in the block. + next: Option, +} + +impl MuteCellCursor<'_> { + /// Pull the cell's next event. + fn pull(&mut self) { + self.next = self.events.next(); + } +} + impl<'a> FaderEventDriver<'a> { /// The block of `frames` samples of every automated lane in `lanes`, `(owner lane, lane's /// automation)`; `hold(lane, channel)` is the stage's state of that channel after the block's @@ -1422,6 +1547,7 @@ impl<'a> FaderEventDriver<'a> { let _ = first_sample; let mut driver = Self { cursors: core::array::from_fn(|_| None), + mutes: core::array::from_fn(|_| None), #[cfg(any(test, feature = "test-support"))] first_sample, }; @@ -1429,17 +1555,20 @@ impl<'a> FaderEventDriver<'a> { return driver; } let mut used = 0_usize; + let mut mutes_used = 0_usize; for (lane, automation) in lanes { let LaneFaderAutomation { program, channels, + mute, span, fader_jump, + mute_jump, fresh, .. } = automation; let first_block = core::mem::replace(fresh, false); - let (Some(span), Some(jump)) = (*span, *fader_jump) else { + let (Some(span), Some(jump), Some(mute_jump)) = (*span, *fader_jump, *mute_jump) else { continue; }; let length = i64::from(frames); @@ -1603,6 +1732,59 @@ impl<'a> FaderEventDriver<'a> { *slot = Some(cursor); used += 1; } + // #1651 D1: the lane's mute cells. A mute cell gives no grid event (a step curve is + // constant between its discontinuities), only its jumps over the block's mute length + // and a seek's exact landing; its first block lands a `Set` when the curve at the + // block's node time differs from its seed, as a fader cell's does (D2, departure 4). + for (channel, cell) in mute.iter_mut().enumerate() { + let Some(AutomatedMuteCell { cell, state, terms }) = cell.as_mut() else { + continue; + }; + let refused = + || debug_assert!(false, "an automated mute 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 keeps its mute, no panic. + let Some(cell_program) = program.cell(*cell as usize) else { + refused(); + 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 && state.target().map(f32::to_bits) != Some(value.to_bits())) + .then_some((0, span.start)) + }); + let muted = hold(lane, channel).is_some_and(|hold| hold.muted); + // Unreachable refusal: the graph's step lies inside the block, and at most + // `MAXIMUM_FADER_CELLS` mute cursors are used (two channels per lane). In release + // the channel is skipped: it emits nothing and the lane keeps its mute. + let (Ok(node), Some(slot)) = ( + automation::NodeSpan::new(span.start, step, frames), + driver.mutes.get_mut(mutes_used), + ) else { + refused(); + continue; + }; + let mut cursor = MuteCellCursor { + lane, + channel, + terms: *terms, + muted, + events: automation::CellEvents::new( + cell_program, + state, + node, + controls::MUTE_CELL_RULE.seek, + mute_jump, + ), + next: None, + }; + cursor.pull(); + *slot = Some(cursor); + mutes_used += 1; + } } driver } @@ -1648,6 +1830,11 @@ impl<'a> FaderEventDriver<'a> { } } } + for cursor in self.mutes.iter_mut().flatten() { + while cursor.next.is_some() { + cursor.pull(); + } + } } } @@ -1807,10 +1994,16 @@ fn hold_end(start: i64, step: Option<(u32, i64)>, at: u32, frames: u32) -> Optio impl builtins::FaderOperationSource for FaderEventDriver<'_> { fn next_offset(&mut self) -> Option { + let mutes = self + .mutes + .iter() + .flatten() + .filter_map(|cursor| cursor.next.map(|event| event.offset)); self.cursors .iter() .flatten() .filter_map(FaderCellCursor::next_offset) + .chain(mutes) .min() } @@ -1849,6 +2042,7 @@ impl builtins::FaderOperationSource for FaderEventDriver<'_> { held: event.held, value: event.value, ramp: event.ramp, + mute: false, }); }; if let Some(event) = event { @@ -1880,6 +2074,47 @@ impl builtins::FaderOperationSource for FaderEventDriver<'_> { } } } + // #1651 D1: each mute cell's event at `offset` composes the stage mute `curve || terms`, + // and only a change pushes `SetMute`: over the event's ramp (the block's mute jump + // length) for a jump, exactly (`0`) for a seek landing. The sink applies it after the + // gain operations of the offset (gain first, #1638 D4). + for cursor in self.mutes.iter_mut().flatten() { + let Some(event) = cursor.next.filter(|event| event.offset == offset) else { + continue; + }; + cursor.pull(); + let muted = curve_mutes(event.value) || cursor.terms; + if muted == cursor.muted { + continue; + } + cursor.muted = muted; + let samples = match event.kind { + automation::EventKind::Set => 0, + automation::EventKind::Grid | automation::EventKind::Jump => event.ramp, + }; + let pushed = sink + .push( + cursor.lane, + channel_selector(cursor.channel), + builtins::FaderOperation::SetMute { muted, samples }, + ) + .is_ok(); + // Unreachable refusal: one mute cursor per lane and channel (no `Duplicate`), the lane + // is a populated member, and a mute operation carries no gain. In release the stage + // keeps its mute, no panic. + debug_assert!(pushed, "a stored mute event is refused at render"); + #[cfg(any(test, feature = "test-support"))] + test_only_record_fader_event(TestOnlyFaderEvent { + lane: cursor.lane, + channel: cursor.channel, + sample: self.first_sample.saturating_add(u64::from(offset)), + kind: event.kind, + held: false, + value: event.value, + ramp: samples, + mute: true, + }); + } } } // REALTIME_POLICY_END @@ -1903,6 +2138,9 @@ pub struct TestOnlyFaderEvent { pub value: f32, /// The ramp length. pub ramp: u32, + /// A mute event (#1651): a `SetMute` the driver pushed, `value` the curve value (`0` or + /// `1`) and `ramp` the mute window; else a fader event. + pub mute: bool, } #[cfg(any(test, feature = "test-support"))] @@ -1930,6 +2168,7 @@ thread_local! { held: false, value: 0.0, ramp: 0, + mute: false, }; FADER_EVENT_CAPACITY], }) }; @@ -1989,19 +2228,45 @@ struct PreparedFaderCell { offsets: Arc, } -/// The stored fader automation of one preparation (#1646): the program every automated lane -/// reads, its byte report, and each automated strip's channels. +/// One automated mute channel as preparation computed it (#1651 D1, batch 18 amendment). +#[derive(Clone, Copy, Debug)] +struct PreparedMuteCell { + /// The cell's index in the program, after every fader cell. + cell: u32, + /// The prepared curve value: the entry's first `start_value` (`0` or `1`), the curve at node + /// time `-a(n)` for every `a(n)` (the hold rule, as #1646 D2 for the fader). + value: f32, + /// `terms`: the lane's mute without the curve, `controls::EffectiveStrip::mute` (OQ1 = C: the + /// strip's own static mute, any reaching VCA's, or the solo term). + terms: bool, +} + +/// Whether a mute curve value mutes: a step curve holds `0` or `1` (#1645 D1); `-0.0` is `0`. +const fn curve_mutes(value: f32) -> bool { + value != 0.0 +} + +/// One automated strip's prepared cells (#1646, #1651): its fader channels and its mute channels, +/// `[left, right]` each; `None` for a channel no entry drives. +#[derive(Clone, Debug)] +struct PreparedStripAutomation { + fader: [Option; 2], + mute: [Option; 2], +} + +/// The stored fader and mute automation of one preparation (#1646, #1651): 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]>, + /// Each strip that has an automated fader or mute lane, by track ID. + strips: BTreeMap, PreparedStripAutomation>, } impl FaderAutomationPlan { - /// The prepared cells of strip `track`, if any channel is automated. - fn strip(&self, track: &str) -> Option<&[Option; 2]> { + /// The prepared cells of strip `track`, if any fader or mute channel is automated. + fn strip(&self, track: &str) -> Option<&PreparedStripAutomation> { self.strips.get(track) } @@ -2012,12 +2277,25 @@ impl FaderAutomationPlan { return effective; }; [0, 1].map(|lane| { - cells[lane] + cells.fader[lane] .as_ref() .map_or(effective[lane], |cell| cell.fader_db) }) } + /// The prepared stage mute of each lane of strip `track` (#1651 D1): `curve || terms` on an + /// automated mute lane, with the curve at its prepared value, `effective` elsewhere. + fn mute(&self, track: &str, effective: [bool; 2]) -> [bool; 2] { + let Some(cells) = self.strip(track) else { + return effective; + }; + [0, 1].map(|lane| { + cells.mute[lane].as_ref().map_or(effective[lane], |cell| { + curve_mutes(cell.value) || cell.terms + }) + }) + } + /// The bytes the program retains: its shared allocation and the builder's report. fn retained_program_bytes(&self) -> Option { fader_automation_sizes() @@ -2025,11 +2303,11 @@ impl FaderAutomationPlan { .checked_add(self.program_bytes) } - /// The automated channels among `members`. + /// The automated fader channels among `members`: one offsets cell each (#1649). 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()) + .map(|cells| cells.fader.iter().filter(|cell| cell.is_some()).count()) .try_fold(0_u64, |total, count| { total.checked_add(u64::try_from(count).ok()?) }) @@ -2065,10 +2343,18 @@ pub fn fader_automation_sizes() -> FaderAutomationSizes { } } -/// 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. +/// Compile the session's stored fader and mute automation (#1646 D1, D2; #1651 D1): 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`), then one cell per automated +/// mute lane in the same order, with the mute row's rule (`controls::MUTE_CELL_RULE`). The mute +/// cells follow every fader cell, so a session with no mute automation builds the program it built +/// before #1651, cell for cell. `None` when no fader or mute lane is automated, so a session +/// without either prepares exactly as before; a session with mute automation only builds mute +/// cells only. +/// +/// Each automated mute lane is seeded with its entry's first `start_value` and with `terms`, the +/// strip's [`controls::EffectiveStrip::mute`] (OQ1 = C); its prepared stage mute is +/// `curve || terms` ([`FaderAutomationPlan::mute`]). /// /// 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)` runs @@ -2097,15 +2383,16 @@ fn prepare_fader_automation( 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()), - ) + let refuse = |code: &'static str, entry: &session::Automation| { + diag(code, &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(); + // One segment table per cell, in program order, with its entry and its row's rule; transient. + let mut tables: Vec<( + &session::Automation, + Vec, + controls::CellRule, + )> = Vec::new(); + let mut strips: BTreeMap, PreparedStripAutomation> = 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) { @@ -2116,7 +2403,10 @@ fn prepare_fader_automation( 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 value = segments + .first() + .ok_or_else(|| refuse("host.automation.fader", entry))? + .start_value; let offsets_sum = values.offsets_sum[lane]; // The caller's body holds `CanonicalFpEnv` across this composition and the lowering // that bakes its gain (D2). @@ -2125,7 +2415,8 @@ fn prepare_fader_automation( // (#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))?; + builtins::checked_fader_gain(fader_db) + .map_err(|_| refuse("host.automation.fader", entry))?; let cell = u32::try_from(tables.len()) .map_err(|_| diag("host.automation.fader", "$.automation"))?; cells[lane] = Some(PreparedFaderCell { @@ -2136,13 +2427,51 @@ fn prepare_fader_automation( offsets_ramp: ramp, offsets: Arc::new(OffsetsLane::new()), }); - tables.push((entry, segments)); + tables.push((entry, segments, controls::FADER_CELL_RULE)); + } + strips.insert( + Box::::from(strip.id.as_str()), + PreparedStripAutomation { + fader: cells, + mute: [None, None], + }, + ); + } + // #1651 D1: the mute cells, after every fader cell. + for (strip, values) in model.strips().zip(effective.strips.iter()) { + let entries = controls::mute_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); + // Defensive: the session validator refuses an entry with no segment. + let value = segments + .first() + .ok_or_else(|| refuse("host.automation.mute", entry))? + .start_value; + let cell = u32::try_from(tables.len()) + .map_err(|_| diag("host.automation.mute", "$.automation"))?; + cells[lane] = Some(PreparedMuteCell { + cell, + value, + terms: values.mute[lane], + }); + tables.push((entry, segments, controls::MUTE_CELL_RULE)); } - strips.insert(Box::::from(strip.id.as_str()), cells); + strips + .entry(Box::::from(strip.id.as_str())) + .or_insert(PreparedStripAutomation { + fader: [None, None], + mute: [None, None], + }) + .mute = cells; } let descriptions: Vec> = tables .iter() - .map(|(_, segments)| controls::FADER_CELL_RULE.describe(segments)) + .map(|(_, segments, rule)| 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 @@ -2160,7 +2489,14 @@ fn prepare_fader_automation( }; cell.and_then(|cell| tables.get(cell)).map_or_else( || diag("host.automation.fader", "$.automation"), - |(entry, _)| refuse(entry), + |(entry, _, _)| { + let code = if controls::mute_row(&entry.target) { + "host.automation.mute" + } else { + "host.automation.fader" + }; + refuse(code, entry) + }, ) })?; Ok(Some(FaderAutomationPlan { @@ -3204,7 +3540,7 @@ impl FaderBankProcessor { automation, .. } = self; - let result = drain_fader_controls(bank, controls, snapshot, EVERY_LANE); + let result = drain_fader_controls(bank, controls, automation, snapshot, EVERY_LANE); read_fader_offsets(bank, controls, automation, snapshot); result } @@ -3225,7 +3561,7 @@ impl FaderBankProcessor { automation, .. } = self; - let result = drain_fader_controls(bank, controls, LiveSnapshot::ALL, lanes); + let result = drain_fader_controls(bank, controls, automation, LiveSnapshot::ALL, lanes); for (lane, slot) in automated_lanes(automation) { if !lane_selected(lanes, lane) { continue; @@ -3316,9 +3652,9 @@ impl GraphPreparedBuiltinBankProcessor for FaderBankProcessor { if self.automation.is_empty() { return; } - let fader_jump = block_fader_jump(time); + let jumps = block_jumps(time); for slot in self.automation.iter_mut().flatten() { - slot.take_block_time(time, fader_jump); + slot.take_block_time(time, jumps); } #[cfg(any(test, feature = "test-support"))] test_only_record_fader_lane_times(&self.automation); @@ -3937,9 +4273,13 @@ fn scalar_owner_id(track_id: &StableGraphId) -> u16 { /// (#1312 D4, D12): every lane for the block drain, the carried lanes for the plan-swap drain. /// Each lane reads and applies in the canonical order, an applied value is never lost to a /// refusal of another, and the first refusal is returned after the last lane. +/// +/// #1651 D1: a mute value of a channel whose mute the lane's stored automation drives is that +/// lane's `terms` ([`LaneFaderAutomation::mute_terms`]), not the stage's mute. fn drain_fader_controls( bank: &mut BuiltinFaderBank, controls: &mut [Option], + automation: &mut [Option], snapshot: LiveSnapshot, lanes: u8, ) -> Result<(), RenderError> { @@ -3951,6 +4291,7 @@ fn drain_fader_controls( let Some(control) = control.as_mut() else { continue; }; + let mut slot = automation.get_mut(lane).and_then(Option::as_mut); let drained = control.apply_latest(snapshot, |kind, lanes, words| { #[cfg(any(test, feature = "test-support"))] FADER_MATRIX_LIVE_WITNESS.with(|value| { @@ -3962,7 +4303,21 @@ 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 => match slot.as_deref_mut() { + Some(slot) => apply_mute_value( + slot, + &mut BankLaneStage { + bank: &mut *bank, + lane, + }, + lanes, + words, + |stage, selector| { + stage.bank.set_mute(lane, selector, words[0] != 0, words[1]) + }, + ), + None => bank.set_mute(lane, lanes, words[0] != 0, words[1]), + }, } }); result = result.and(drained.map_err(render_error)); @@ -3971,6 +4326,33 @@ fn drain_fader_controls( } // REALTIME_POLICY_END +// REALTIME_POLICY_BEGIN +/// One drained mute value `words` (`0`/`1`, then the ramp word) of the channels `lanes` covers on +/// a lane with stored automation (#1651 D1): a channel whose mute the lane automates takes it as +/// its `terms` ([`LaneFaderAutomation::mute_terms`]); every other channel's goes to the stage, as +/// on a lane with no automation (`set_mute(stage, selector)`). The first refusal is returned. +fn apply_mute_value( + slot: &mut LaneFaderAutomation, + stage: &mut S, + lanes: BuiltinLaneSelector, + words: [u32; FADER_CELL_WORDS], + mut set_mute: impl FnMut(&mut S, BuiltinLaneSelector) -> Result<(), BuiltinParameterError>, +) -> Result<(), BuiltinParameterError> { + if !(0..2).any(|channel| slot.automates_mute(channel)) { + return set_mute(stage, lanes); + } + let mut result = Ok(()); + for channel in selected_channels(lanes) { + if slot.automates_mute(channel) { + slot.mute_terms(channel, words[0] != 0, words[1], stage); + } else { + result = result.and(set_mute(stage, channel_selector(channel))); + } + } + result +} +// REALTIME_POLICY_END + // REALTIME_POLICY_BEGIN /// The matrix counterpart of [`drain_fader_controls`]. fn drain_matrix_controls( @@ -5172,6 +5554,27 @@ impl PreparedBuiltinsSession { self.track_controls.len() } + /// Strip `track_id`'s stored mute automation (#1651 D1), for the route lanes of #1652 that + /// follow it: the plan's one automation program (the same `Arc` every fader owner of the plan + /// reads) and the program's mute cell index of each lane, `[left, right]`, `None` for a lane + /// no mute entry drives. `None` when no lane of the strip automates its mute (or the session + /// has no stored automation that renders). Control side; it clones one `Arc`. + #[must_use] + pub fn strip_mute_cells( + &self, + track_id: &str, + ) -> Option<(Arc, [Option; 2])> { + let plan = self.fader_automation.as_ref()?; + let cells = plan + .strip(track_id)? + .mute + .map(|cell| cell.map(|cell| cell.cell)); + cells + .iter() + .any(Option::is_some) + .then(|| (Arc::clone(&plan.program), cells)) + } + /// Every route's effective follow gate, `[left, right]` per source lane, in `model.routes` /// order, as it was prepared with (decision 17, C7): `follows_mute` on a muted lane of the /// route's source strip, composed once by `controls::effective`. Control-side data like @@ -5526,7 +5929,15 @@ impl PreparedBuiltinsSession { _ => None, }, ))?; - if channels == 0 { + // #1651: a bank whose members automate only their mute holds the slots too. + if channels == 0 + && !members.iter().any(|member| match member { + GraphNodeId::TrackStage { track_id, .. } => { + automation.strip(track_id.as_str()).is_some() + } + _ => false, + }) + { continue; } payload_bytes = payload_bytes @@ -6773,12 +7184,17 @@ fn prepare_session_builtins_with_live_controls_and_policy( 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). + // prepared value composed with its `S` (D2); its effective mute, and on an automated mute + // lane the prepared `curve || terms` (#1651 D1). 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) + fader_automation + .as_ref() + .map_or((fader.fader_db, fader.mute), |plan| { + ( + plan.fader_db(strip.id.as_str(), fader.fader_db), + plan.mute(strip.id.as_str(), 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 @@ -6885,7 +7301,7 @@ fn prepare_session_builtins_with_live_controls_and_policy( .as_ref() .and_then(|plan| plan.strip(strip.id.as_str())) { - fader_producer.attach_offsets(cells); + fader_producer.attach_offsets(&cells.fader); } let (input_producer, input_control) = if input_lane { let (cells, reader) = input_lane_cells(superseded); @@ -7514,8 +7930,7 @@ impl GraphRuntimeProcessor for AutomatedFaderProcessor { self.automation.arrival = arrival; } fn set_block_time(&mut self, time: &GraphBlockTime<'_>) { - self.automation - .take_block_time(time, block_fader_jump(time)); + self.automation.take_block_time(time, block_jumps(time)); } fn channel_symmetry(&self) -> ChannelSymmetryWitness { SEAM_SIDE_WITNESS @@ -7776,7 +8191,7 @@ impl GraphRuntimeProcessor for LiveControlFaderProcessor { } 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)); + automation.take_block_time(time, block_jumps(time)); } } fn scalar_pair_factory(&self) -> Option { @@ -7794,6 +8209,7 @@ impl LiveControlFaderProcessor { /// The fader and mute cells' drain under the block's `snapshot` (#1312 D12). fn drain_controls(&mut self, snapshot: LiveSnapshot) -> Result<(), RenderError> { let fader = &mut self.fader; + let mut slot = self.automation.as_deref_mut(); let result = self .control .apply_latest(snapshot, |kind, lanes, words| { @@ -7807,10 +8223,19 @@ impl LiveControlFaderProcessor { FaderKind::Fader => { fader.set_fader_db(lanes, f32::from_bits(words[0]), words[1]) } - FaderKind::Mute => { - fader.set_mute(lanes, words[0] != 0, words[1]); - Ok(()) - } + // #1651 D1: an automated mute channel's value is its lane's `terms`. + FaderKind::Mute => match slot.as_deref_mut() { + Some(slot) => { + apply_mute_value(slot, &mut *fader, lanes, words, |fader, selector| { + fader.set_mute(selector, words[0] != 0, words[1]); + Ok(()) + }) + } + None => { + fader.set_mute(lanes, words[0] != 0, words[1]); + Ok(()) + } + }, } }) .map_err(render_error); @@ -13624,6 +14049,7 @@ mod tests { drain_fader_controls( &mut fixture.paired.fader.bank, &mut fixture.paired.fader.controls, + &mut fixture.paired.fader.automation, snapshot, EVERY_LANE, ) @@ -15402,6 +15828,131 @@ mod tests { assert!(with.largest_allocation_bytes >= slot.max(sizes.offsets_cell_bytes).max(program)); } + /// #1651 D1: the mute cells follow every fader cell in the plan's one program, so the fader + /// cells keep their indices and tables, and [`PreparedBuiltinsSession::strip_mute_cells`] + /// (the export #1652's route lanes read) names each mute lane's cell in that same program; a + /// session with no mute entry exports none, and one with a mute entry only builds mute cells + /// only. The prepared stage mute of the automated lane is `curve || terms`. + /// + /// Test value: red if a mute cell is placed before a fader cell (the fader table moves and a + /// fader-only session no longer builds the program it did), if the export names another + /// program or cell, or if a mute-only session builds no program. + #[test] + fn the_mute_cells_follow_the_fader_cells_in_the_one_program() { + let plain = n_track_session(3); + let entry = |name: &str, track: usize, control: u32, lane, segment| session::Automation { + id: session::StableId::parse(name).expect("id"), + target: session::ControlAddress { + owner: session::Owner::Strip( + session::StableId::parse(&track_name(track)).expect("track"), + ), + section: session::Section::Builtins, + control, + lane, + }, + segments: vec![segment], + offset: session::AutomationOffset::default(), + }; + let fader = entry( + "fader-t01", + 1, + 5, + session::ParameterChannel::Left, + 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, + }, + ); + let mute = entry( + "mute-t00", + 0, + 6, + session::ParameterChannel::Both, + session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: 0, + end_sample: 1_000, + start_value: 1.0, + end_value: 0.0, + unit: session::ParameterUnit::Linear, + }, + ); + let caps_all = 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, + }; + let prepare = |automation: Vec| { + let mut model = plain.normalized_model().clone(); + model.automation = automation; + let compiled = compile_session(&model, caps_all).expect("the session compiles"); + prepare_session_builtins(&compiled, &[], caps()).expect("prepared") + }; + let fader_only = prepare(vec![fader.clone()]); + let both = prepare(vec![mute.clone(), fader.clone()]); + let mute_only = prepare(vec![mute.clone()]); + let fader_segments = controls::stored_segments(&fader); + let mute_segments = controls::stored_segments(&mute); + + let only = fader_only.fader_automation.as_ref().expect("fader cells"); + assert_eq!( + only.program_bytes, + automation::ProgramBuilder::bytes(&[ + controls::FADER_CELL_RULE.describe(&fader_segments) + ]) + ); + assert!((0..3).all(|track| fader_only.strip_mute_cells(&track_name(track)).is_none())); + + let plan = both.fader_automation.as_ref().expect("cells"); + assert_eq!( + plan.program_bytes, + automation::ProgramBuilder::bytes(&[ + controls::FADER_CELL_RULE.describe(&fader_segments), + controls::MUTE_CELL_RULE.describe(&mute_segments), + controls::MUTE_CELL_RULE.describe(&mute_segments), + ]), + "the fader cell first, then the mute cells" + ); + let fader_cell = plan.strip(&track_name(1)).expect("track 1").fader[0] + .as_ref() + .expect("left fader cell") + .cell; + assert_eq!(fader_cell, 0, "the fader cell keeps its index"); + let (program, cells) = both + .strip_mute_cells(&track_name(0)) + .expect("track 0 automates its mute"); + assert!( + Arc::ptr_eq(&program, &plan.program), + "the plan's one program" + ); + assert_eq!(cells, [Some(1), Some(2)]); + for cell in cells.into_iter().flatten() { + let segments = program.cell(cell as usize).expect("cell").segments(); + assert_eq!(segments.len(), mute_segments.len()); + assert_eq!(segments[0].start_value.to_bits(), 1.0_f32.to_bits()); + } + assert!(both.strip_mute_cells(&track_name(1)).is_none()); + assert_eq!( + plan.mute(&track_name(0), [false, false]), + [true, true], + "a curve that starts at 1 prepares the lane muted" + ); + assert_eq!(plan.mute(&track_name(1), [false, true]), [false, true]); + + let (program, cells) = mute_only + .strip_mute_cells(&track_name(0)) + .expect("a mute-only session builds its mute cells"); + assert_eq!(cells, [Some(0), Some(1)]); + assert!(program.cell(2).is_none(), "mute cells only"); + } + struct HarnessSink; impl GraphRuntimeProcessor for HarnessSink { diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index 1e9b6449e..080fdb53f 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::{FADER_DB, HPF_HZ, LPF_HZ}; +use crate::builtin_rows::{FADER_DB, HPF_HZ, LPF_HZ, MUTE}; use crate::descriptor::{ControlDescriptor, Lanes, check}; use crate::effect_rows::{has_rows, session_unit}; use crate::error::ControlError; @@ -375,16 +375,39 @@ pub const FADER_CELL_RULE: CellRule = CellRule { seek: SeekMode::Exact, }; -/// Whether a stored automation of `target` renders (#1646, #1647): today the fader row of a strip -/// (builtin row 5, [`fader_row`]). 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. +/// The mute row's cell rule (#1651 D1, batch 18 amendment): the fader row's rule. A mute curve is +/// `step` only with values 0 and 1 (#1645 D1), so it is constant between its discontinuities and +/// its cell gives no grid event, only jumps and seek landings: completion `End` (a mute ramp +/// assigns its target on its last frame, as the fader ramp does), the jump length of the block +/// (`controls::LiveRamps::for_row(LiveRampRow::Mute)`, resolved by the caller), restartable, and an +/// exact seek landing (a builtin cell). +pub const MUTE_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, #1651): the fader row of a +/// strip (builtin row 5, [`fader_row`]) and its mute row (builtin row 6, [`mute_row`]). 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. /// -/// A reader that means "the fader" tests [`fader_row`] instead (#1651 T2): a rendered row added -/// here (the mute row, #1651) gives no lane a fader cell, a hand-move offset or B17-R14's window. +/// A reader that means "the fader" tests [`fader_row`] instead (#1651 T2): the mute row gives no +/// lane a fader cell, a hand-move offset or B17-R14's window. #[must_use] pub fn automation_row_renders(target: &ControlAddress) -> bool { - fader_row(target) + fader_row(target) || mute_row(target) +} + +/// Whether `target` is a strip's builtin `mute` row (row 6): the rendered row whose entry gives a +/// lane its mute cell ([`mute_automation`], #1651). +#[must_use] +pub fn mute_row(target: &ControlAddress) -> bool { + matches!(target.owner, Owner::Strip(_)) + && target.section == Section::Builtins + && target.control == MUTE } /// Whether `target` is a strip's builtin `fader_db` row (row 5): the one rendered row whose entry @@ -406,11 +429,31 @@ pub fn fader_row(target: &ControlAddress) -> bool { pub fn fader_automation<'a>( model: &'a SessionModel, strip: &StableId, +) -> [Option<&'a Automation>; 2] { + row_automation(model, strip, fader_row) +} + +/// The stored automation entries that drive strip `strip`'s mute, per lane (`[left, right]`), by +/// the rule of [`fader_automation`] on the mute row ([`mute_row`], #1651). +#[must_use] +pub fn mute_automation<'a>( + model: &'a SessionModel, + strip: &StableId, +) -> [Option<&'a Automation>; 2] { + row_automation(model, strip, mute_row) +} + +/// The entries on the row `row` accepts that drive strip `strip`, per lane; the first entry per +/// lane in table order. +fn row_automation<'a>( + model: &'a SessionModel, + strip: &StableId, + row: fn(&ControlAddress) -> bool, ) -> [Option<&'a Automation>; 2] { let mut lanes = [None, None]; for automation in &model.automation { let target = &automation.target; - if !fader_row(target) || !matches!(&target.owner, Owner::Strip(owner) if owner == strip) { + if !row(target) || !matches!(&target.owner, Owner::Strip(owner) if owner == strip) { continue; } for (index, lane) in [Lane::Left, Lane::Right].into_iter().enumerate() { @@ -432,7 +475,8 @@ fn drives(entry: Lane, lane: Lane) -> bool { /// 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. +/// ([`fader_automation`] for the fader row, [`mute_automation`] for the mute 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). diff --git a/crates/controls/src/builtin_rows.rs b/crates/controls/src/builtin_rows.rs index e5d49148a..a44910ea5 100644 --- a/crates/controls/src/builtin_rows.rs +++ b/crates/controls/src/builtin_rows.rs @@ -27,7 +27,7 @@ const TRIM_DB: u32 = 2; pub(crate) const HPF_HZ: u32 = 3; pub(crate) const LPF_HZ: u32 = 4; pub(crate) const FADER_DB: u32 = 5; -const MUTE: u32 = 6; +pub(crate) const MUTE: u32 = 6; const MATRIX_LL: u32 = 7; const MATRIX_LR: u32 = 8; const MATRIX_RL: u32 = 9; diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index f019f52bf..de926ee01 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -45,10 +45,10 @@ mod vca; mod view; pub use automation::{ - CellRule, ControlDiagnostic, FADER_CELL_RULE, automated_cell, automated_fader_lanes, - automation_diagnostics, automation_row_renders, continued_fader_lanes, fader_automation, - fader_row, filter_statics_changed_under_automation, filter_under_automation, - rendered_automation_changed, stored_segments, + CellRule, ControlDiagnostic, FADER_CELL_RULE, MUTE_CELL_RULE, automated_cell, + automated_fader_lanes, automation_diagnostics, automation_row_renders, continued_fader_lanes, + fader_automation, fader_row, filter_statics_changed_under_automation, filter_under_automation, + mute_automation, mute_row, rendered_automation_changed, stored_segments, }; pub use descriptor::{ AutomationRule, Choice, ControlDescriptor, ControlScope, DomainRule, Lanes, Motion, check, diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index 3f03604b1..754a752d7 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -272,11 +272,12 @@ pub use crate::prepare::LiveRebuild; /// 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. +/// strip's fader and mute, builtin rows 5 and 6, [`controls::automation_row_renders`], #1651) +/// 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 @@ -317,7 +318,10 @@ pub use crate::prepare::LiveRebuild; /// 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. +/// reaches the lane too. The lane's `Mute` records are unchanged: on a lane whose mute `next` +/// automates (#1651 D1, OQ1 = C) the record still carries the effective mute +/// ([`controls::EffectiveStrip::mute`]: the strip's own static mute, any reaching VCA's, or the +/// solo term), which is the lane's `terms`, and render composes the stage mute `curve || terms`. /// /// 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 diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index d0488f723..af01c20de 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -5007,8 +5007,9 @@ 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, + /// The stored automation of a row that renders ([`controls::automation_row_renders`]: a + /// strip's fader and mute, #1648, #1651) 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. diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index c242aa96d..1ab2cb5de 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -1361,14 +1361,14 @@ fn left_fader_ride(model: &SessionModel) -> Automation { /// #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. +/// `Automation`; a change of its hand offset alone is not (departure 3). A mute entry (row 6, +/// which renders since #1651 T3a) added, changed or removed is `Automation` too. 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 value: red if the mask still hides fader or mute entries (an edit is acked and never +/// heard), 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(); @@ -1410,8 +1410,7 @@ fn a_fader_automation_edit_is_a_rebuild() { .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. + // The mute row renders too (#1651 T3a): a mute entry added, changed and removed is a rebuild. let mute_ride = |model: &SessionModel| { let mut ride = fader_ride(model); ride.id = id("mute-ride"); @@ -1435,18 +1434,18 @@ fn a_fader_automation_edit_is_a_rebuild() { let ride = mute_ride(next); next.automation.push(ride); }), - Ok(Vec::new()), + Err(LiveRebuild::Automation), "a mute entry added" ); assert_eq!( classify(&muted, |next| next.automation[0].segments[0].end_sample = 1_920), - Ok(Vec::new()), + Err(LiveRebuild::Automation), "a mute entry changed" ); assert_eq!( classify(&muted, |next| next.automation.clear()), - Ok(Vec::new()), + Err(LiveRebuild::Automation), "a mute entry removed" ); // A static change in the same transaction as an automation change is a rebuild. diff --git a/crates/host-core/tests/stored_mute_automation.rs b/crates/host-core/tests/stored_mute_automation.rs new file mode 100644 index 000000000..bbca16376 --- /dev/null +++ b/crates/host-core/tests/stored_mute_automation.rs @@ -0,0 +1,693 @@ +//! Stored mute automation on the strip (#1651 T3a): a mute cell renders its steps on the fader +//! stage at their exact samples over the session mute length, through the one event driver, on +//! every bank width and on the scalar oracle (R17-4); a seek that leaves the stage mute unchanged +//! lets a ramp toward that state finish (root ruling R18-1); and a mute value the strip's live cell +//! carries is the lane's `terms`, which render ORs with the curve (OQ1 = C). +//! +//! Every test prepares on a chosen backend, a `test-support` entry of host-core. +#![cfg(feature = "test-support")] + +use engine::realtime::{PlanarBufferMut, RenderIo, RenderTime}; +use host_core::{HostPrepareCaps, HostShapePolicy, PreparedHost, SourceSubmission}; +use session::{ + Automation, AutomationSegment, AutomationShape, ControlAddress, ControlSmoothing, Owner, + ParameterChannel, ParameterUnit, Section, SessionModel, StableId, canonical_session_json, + parse_session_json, +}; + +const NINE: &str = include_str!("../../../fixtures/session/v1/parametric-eq-nine-track.json"); +const NINE_SOURCE: &[u8] = b"fixture-source"; +const RATE: u32 = 48_000; +/// The mute row (builtin parameter 6). +const MUTE: u32 = 6; +/// The fader row (builtin parameter 5). +const FADER_DB: u32 = 5; +/// The session mute length of every test here: `mute_ms` 10 at 48 kHz. +const M: u32 = 480; + +fn id(text: &str) -> StableId { + StableId::parse(text).expect("stable id") +} + +fn caps(model: &SessionModel) -> HostPrepareCaps { + HostPrepareCaps { + shape: HostShapePolicy::AnyLaunchRate, + source_ring_frames: 32 * model.quantum_frames, + 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: 1_000_000_000, + 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, + } +} + +/// The first `tracks` tracks of the nine-track fixture at quantum `quantum`: no console, no +/// insert, no input filter, an identity matrix, each routed to the output, `control_smoothing` +/// explicit (fader 5 ms, mute 10 ms). 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.automation.clear(); + 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 = Some(ControlSmoothing { + mute_ms: 10.0, + fader_ms: 5.0, + pan_ms: 5.0, + }); + assert_eq!(controls::LiveRamps::for_session(&model).mute_samples, M); + model +} + +/// A mute entry on `lane` of track `track` with steps at each `(sample, value)` of `steps`, from +/// `from` (`0` unmuted, `1` muted). +fn mute_entry( + model: &SessionModel, + track: usize, + lane: ParameterChannel, + from: f32, + steps: &[(u64, f32)], +) -> Automation { + let mut at = (0_u64, from); + let mut segments: Vec = steps + .iter() + .map(|&(sample, value)| { + let segment = AutomationSegment { + shape: AutomationShape::Step, + start_sample: at.0, + end_sample: sample, + start_value: at.1, + end_value: value, + unit: ParameterUnit::Linear, + }; + at = (sample, value); + segment + }) + .collect(); + if segments.is_empty() { + segments.push(AutomationSegment { + shape: AutomationShape::Step, + start_sample: 0, + end_sample: 1, + start_value: from, + end_value: from, + unit: ParameterUnit::Linear, + }); + } + Automation { + id: id(&format!("mute-{track}")), + target: ControlAddress { + owner: Owner::Strip(model.tracks[track].id.clone()), + section: Section::Builtins, + control: MUTE, + lane, + }, + segments, + offset: session::AutomationOffset::default(), + } +} + +/// A plan of `model` rendered block by block on a DC source of `1.0`. +struct Rig { + prepared: PreparedHost, + handles: Option, + gate: Option, + quantum: usize, + generation: u64, + frame: u64, + rendered: u64, + out: [Vec; 2], + /// The render calls' allocations, reallocations and frees. + allocations: (u64, u64, u64), +} + +/// How a [`Rig`] is prepared: a backend, live controls, and delivery between render calls. +#[derive(Clone, Copy, Debug)] +struct Form { + backend: graph_compiler::Backend, + live: bool, + between: bool, +} + +/// Every form of the target: Simd8 (on an AVX2 target only), Simd4 and the scalar oracle, each +/// with no live control, with live controls, and with live controls between render calls (the +/// fused fader-matrix pair). +fn forms() -> Vec
{ + let backends = [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + graph_compiler::Backend::Scalar, + ]; + let mut forms = Vec::new(); + for backend in backends { + for (live, between) in [(false, false), (true, false), (true, true)] { + forms.push(Form { + backend, + live, + between, + }); + } + } + forms +} + +impl Rig { + fn new(model: &SessionModel, form: Form) -> Self { + let document = canonical_session_json(model).expect("canonical"); + let caps = 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 (mut prepared, handles) = if !form.live { + ( + host_core::test_only_prepare_host_runtime_on(&compiled, &caps, form.backend) + .unwrap_or_else(|failure| { + panic!("{}", String::from_utf8_lossy(failure.as_bytes())) + }), + None, + ) + } else { + let (prepared, handles) = if form.between { + host_core::prepare::test_only_prepare_host_runtime_between_render_calls_on( + &compiled, + &caps, + &request, + form.backend, + ) + } else { + host_core::test_only_prepare_host_runtime_with_live_controls_on( + &compiled, + &caps, + &request, + form.backend, + ) + } + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + (prepared, Some(handles)) + }; + let gate = if handles.is_some() { + prepared.plan.take_revision_gate() + } else { + None + }; + Self { + prepared, + handles, + gate, + quantum: model.quantum_frames as usize, + generation: 1, + frame: 0, + rendered: 0, + out: [Vec::new(), Vec::new()], + allocations: (0, 0, 0), + } + } + + /// A plain session seek to timeline sample `timeline` (#1641). + 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; + } + + /// Write a live `Mute` record on track 0's both lanes over `window`, applied at the next + /// block entry: under OQ1 = C the shared commit's record of the effective mute, `terms`. + fn live_mute(&mut self, muted: bool, window: u32) { + use builtins_compiler::TestOnlyWriteGate; + let handles = self.handles.as_mut().expect("live controls"); + self.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, + smoothing_samples: window, + }, + ); + }); + } + + /// 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; the output cut to `frames`. + fn run(mut self, frames: u64) -> ([Vec; 2], Self) { + while self.rendered < frames { + self.block(); + } + let out = self.out.clone().map(|mut plane| { + plane.truncate(frames as usize); + plane + }); + (out, self) + } +} + +/// The stage driven directly: a width-one ramped fader at `0` dB, unmuted, given each +/// `(sample, operation)` of `operations` at its sample (#1638), over `frames` samples of DC `1.0`. +fn reference(operations: &[(u64, builtins::FaderOperation)], frames: u64) -> Vec { + let mut stage = builtins::FaderMuteRampBuiltins::new(builtins::BuiltinParameters::default()) + .expect("default parameters"); + 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 +} + +const fn set_mute(muted: bool, samples: u32) -> builtins::FaderOperation { + builtins::FaderOperation::SetMute { muted, samples } +} + +/// 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()) +} + +/// The mute events the driver pushed since the last take, as `(channel, sample, muted, ramp)`. +fn mute_events() -> Vec<(usize, u64, bool, u32)> { + let (events, overflow) = builtins_compiler::test_only_take_fader_events(); + assert!(!overflow); + events + .iter() + .filter(|event| event.mute) + .map(|event| (event.channel, event.sample, event.value != 0.0, event.ramp)) + .collect() +} + +/// #1651 gate 1 (widths, T3a): a mute entry on `both` of track 0 with steps 0, 1, 0 at samples +/// 1,000 and 2,500 (not grid samples). On every form (Simd8 on AVX2, Simd4 and the scalar oracle; +/// with no live control, with live controls, and between render calls) and at quanta 128, 100 and +/// 37, the driver pushes `SetMute` at exactly those samples over the session mute length `M`, and +/// the output is bit for bit the stage driven directly with those two operations: each ramp +/// starts at its step sample, the lane is the exact `+0.0` of a static mute from the end of the +/// mute ramp, and back at the fader from the end of the unmute ramp. Render allocates nothing. +/// +/// Test value: red if a mute step snaps to the grid or a block boundary, ramps over another row's +/// length (the fader's 240), fires twice or not at all, depends on the quantum or the form, or if +/// the scalar oracle or the fused pair skips the mute cells. +#[test] +fn a_mute_step_mutes_the_lane_at_its_exact_sample_over_the_mute_length() { + const FRAMES: u64 = 4_096; + let expected = reference( + &[(1_000, set_mute(true, M)), (2_500, set_mute(false, M))], + FRAMES, + ); + assert_eq!(expected[999].to_bits(), 1.0_f32.to_bits()); + assert_eq!( + expected[1_000 + M as usize - 1].to_bits(), + 0.0_f32.to_bits() + ); + assert_eq!( + expected[2_500 + M as usize - 1].to_bits(), + 1.0_f32.to_bits() + ); + for quantum in [128, 100, 37] { + let mut model = plain(1, quantum); + model.automation = vec![mute_entry( + &model, + 0, + ParameterChannel::Both, + 0.0, + &[(1_000, 1.0), (2_500, 0.0)], + )]; + for form in forms() { + let _ = builtins_compiler::test_only_take_fader_events(); + let (out, rig) = Rig::new(&model, form).run(FRAMES); + let label = format!("q={quantum} {form:?}"); + assert_eq!( + mute_events(), + [ + (0, 1_000, true, M), + (1, 1_000, true, M), + (0, 2_500, false, M), + (1, 2_500, false, M) + ], + "{label}" + ); + for plane in &out { + assert_eq!(first_difference(plane, &expected), None, "{label}"); + } + drop(rig); + } + } + // Render is allocation-free after a warm-up, with a mute event in the measured block. + let mut model = plain(1, 128); + model.automation = vec![mute_entry( + &model, + 0, + ParameterChannel::Both, + 0.0, + &[(1_000, 1.0), (2_500, 0.0)], + )]; + bench_support::alloc::assert_installed(); + let mut rig = Rig::new(&model, forms()[0]); + // The first block warms the process statics. + rig.block(); + rig.allocations = (0, 0, 0); + while rig.rendered < FRAMES { + rig.block(); + } + assert_eq!(rig.allocations, (0, 0, 0), "mute events allocate nothing"); +} + +/// #1651 D1, a lane whose mute starts muted: a curve that starts at `1` prepares the stage muted +/// (the hold rule: the curve before its first segment is its first `start_value`), so the lane is +/// the exact `+0.0` from sample 0 and its first block emits nothing; the unmute step at 777 ramps +/// over `M`. A curve on the left lane only leaves the right lane at the fader. +/// +/// Test value: red if preparation bakes the static mute instead of `curve || terms`, if a fresh +/// lane's first block lands a `Set` it does not need, or if a `left` entry reaches the right lane. +#[test] +fn a_curve_that_starts_muted_prepares_the_lane_muted() { + const FRAMES: u64 = 2_048; + let muted_start = { + let mut stage = + builtins::FaderMuteRampBuiltins::new(builtins::BuiltinParameters::default()) + .expect("default parameters"); + stage.set_mute(builtins::BuiltinLaneSelector::Both, true, 0); + let mut left = vec![1.0_f32; 777]; + let mut right = vec![1.0_f32; 777]; + stage.process(builtins::DualMonoBlock::new(&mut left, &mut right, 0).expect("block")); + assert!(left.iter().all(|sample| sample.to_bits() == 0)); + stage + .apply_fader_operation(builtins::BuiltinLaneSelector::Both, set_mute(false, M)) + .expect("unmute"); + let mut tail_l = vec![1.0_f32; (FRAMES - 777) as usize]; + let mut tail_r = tail_l.clone(); + stage.process(builtins::DualMonoBlock::new(&mut tail_l, &mut tail_r, 777).expect("tail")); + left.extend_from_slice(&tail_l); + left + }; + let mut model = plain(1, 128); + model.automation = vec![mute_entry( + &model, + 0, + ParameterChannel::Left, + 1.0, + &[(777, 0.0)], + )]; + for form in forms() { + let _ = builtins_compiler::test_only_take_fader_events(); + let (out, _) = Rig::new(&model, form).run(FRAMES); + assert_eq!(mute_events(), [(0, 777, false, M)], "{form:?}"); + assert_eq!(first_difference(&out[0], &muted_start), None, "{form:?}"); + assert!( + out[1] + .iter() + .all(|sample| sample.to_bits() == 1.0_f32.to_bits()), + "{form:?}: the right lane is not automated" + ); + } +} + +/// Root ruling R18-1 (#1647 constraint 5, the batch 13b rule): a session seek during an unmute +/// ramp to a timeline point where the curve is also unmuted lands a `Set` that changes nothing, so +/// the ramp finishes: the output is bit for bit the twin with no seek. A seek into the curve's +/// muted span instead sets the lane muted exactly (`n = 0`) from the seek's render sample. +/// +/// Test value: red if a seek `Set` that repeats the stage's mute restarts, snaps or cuts the unmute +/// ramp in flight (R18-1), or if a seek into a muted span ramps or lands late. +#[test] +fn a_seek_that_leaves_the_mute_unchanged_lets_the_unmute_ramp_finish() { + const FRAMES: u64 = 4_096; + // Muted on [1,000, 2,000) of the timeline; the unmute ramp runs [2,000, 2,000 + M). + let mut model = plain(1, 128); + model.automation = vec![mute_entry( + &model, + 0, + ParameterChannel::Both, + 0.0, + &[(1_000, 1.0), (2_000, 0.0)], + )]; + // The seek is pushed before the block at render sample 2,176, inside the unmute ramp. + let seek_at = 17 * 128; + assert!(seek_at > 2_000 && seek_at < 2_000 + u64::from(M)); + for form in forms() { + let (twin, _) = Rig::new(&model, form).run(FRAMES); + let mut rig = Rig::new(&model, form); + while rig.rendered < seek_at { + rig.block(); + } + let _ = builtins_compiler::test_only_take_fader_events(); + rig.seek(3_000); + let (out, _) = rig.run(FRAMES); + assert_eq!(mute_events(), [], "{form:?}: the seek changes nothing"); + for plane in 0..2 { + assert_eq!( + first_difference(&out[plane], &twin[plane]), + None, + "{form:?}: plane {plane}" + ); + } + assert_ne!( + twin[0][seek_at as usize].to_bits(), + 1.0_f32.to_bits(), + "the seek lands inside the ramp" + ); + + // The discriminating partner: a seek into the muted span. + let mut rig = Rig::new(&model, form); + while rig.rendered < seek_at { + rig.block(); + } + let _ = builtins_compiler::test_only_take_fader_events(); + rig.seek(1_500); + let r = rig.block(); + assert_eq!( + mute_events(), + [(0, r, true, 0), (1, r, true, 0)], + "{form:?}: the seek sets the lane muted exactly" + ); + for plane in &rig.out { + assert!( + plane[r as usize..] + .iter() + .all(|sample| sample.to_bits() == 0), + "{form:?}: muted exactly from the seek" + ); + } + } +} + +/// #1651 D1, the drain branch (OQ1 = C): on a lane whose mute is automated, a `Mute` record of the +/// strip's live cell is the lane's `terms`, and render composes `curve || terms`. Track 0's curve +/// is unmuted until 2,000, muted on [2,000, 3,000) and unmuted after. A hand mute (`terms` on, +/// window 300) at the block entry 1,024 mutes the open curve there; the curve's mute step at 2,000 +/// changes nothing (no event); a hand unmute (`terms` off) at 2,560 leaves the closed curve +/// closed; the curve's unmute step at 3,000 opens the lane over `M`. Bit for bit the stage driven +/// directly with `SetMute(true, 300)` at 1,024 and `SetMute(false, M)` at 3,000, on every live +/// form. +/// +/// Test value: red if the drain hands an automated lane's mute value to the stage (the hand unmute +/// would open the closed curve at 2,560), if a curve event ignores `terms` (the step at 2,000 would +/// emit, the one at 3,000 would not compose), or if a terms write that changes nothing retargets. +#[test] +fn a_hand_mute_on_an_automated_lane_composes_with_the_curve() { + const FRAMES: u64 = 4_096; + let mut model = plain(1, 128); + model.automation = vec![mute_entry( + &model, + 0, + ParameterChannel::Both, + 0.0, + &[(2_000, 1.0), (3_000, 0.0)], + )]; + let expected = reference( + &[(1_024, set_mute(true, 300)), (3_000, set_mute(false, M))], + FRAMES, + ); + for form in forms().into_iter().filter(|form| form.live) { + let _ = builtins_compiler::test_only_take_fader_events(); + let mut rig = Rig::new(&model, form); + while rig.rendered < FRAMES { + match rig.rendered { + 1_024 => rig.live_mute(true, 300), + 2_560 => rig.live_mute(false, 300), + _ => {} + } + rig.block(); + } + assert_eq!( + mute_events(), + [(0, 3_000, false, M), (1, 3_000, false, M)], + "{form:?}: only the step that changes `curve || terms` emits" + ); + for plane in &rig.out { + assert_eq!(first_difference(plane, &expected), None, "{form:?}"); + } + } +} + +/// #1651 D1: the mute cells follow every fader cell in the plan's one program, so a fader ride +/// renders the same bits with or without a mute entry beside it: track 0's fader steps from `0` to +/// `-6` dB at 1,000 on its left lane, and a mute entry on track 1 (and on track 0's right lane) +/// changes no bit of track 0's left lane on any form. +/// +/// Test value: red if a mute cell takes a fader cell's index or slot (the fader lane would read +/// the mute table), or if a lane's mute cells reach its fader channel. +#[test] +fn a_mute_entry_leaves_a_fader_ride_as_it_was() { + const FRAMES: u64 = 2_048; + let mut fader_only = plain(2, 128); + fader_only.automation = vec![Automation { + id: id("ride-0"), + target: ControlAddress { + owner: Owner::Strip(fader_only.tracks[0].id.clone()), + section: Section::Builtins, + control: FADER_DB, + lane: ParameterChannel::Left, + }, + segments: vec![ + AutomationSegment { + shape: AutomationShape::Linear, + start_sample: 0, + end_sample: 700, + start_value: -20.0, + end_value: 0.0, + unit: ParameterUnit::Db, + }, + AutomationSegment { + shape: AutomationShape::Step, + start_sample: 700, + end_sample: 1_000, + start_value: 0.0, + end_value: -6.0, + unit: ParameterUnit::Db, + }, + ], + offset: session::AutomationOffset::default(), + }]; + // The output sums the tracks, so track 1 is muted statically and its curve never opens it + // (`terms` holds it); the mute entry on track 0's right lane moves only that lane. + fader_only.tracks[1].fader.left_mute = true; + fader_only.tracks[1].fader.right_mute = true; + let mut with_mute = fader_only.clone(); + with_mute.automation.push(mute_entry( + &with_mute, + 1, + ParameterChannel::Both, + 0.0, + &[(500, 1.0)], + )); + let mut right = mute_entry(&with_mute, 0, ParameterChannel::Right, 0.0, &[(900, 1.0)]); + right.id = id("mute-0-right"); + with_mute.automation.push(right); + for form in forms() { + let (base, _) = Rig::new(&fader_only, form).run(FRAMES); + let (out, _) = Rig::new(&with_mute, form).run(FRAMES); + assert_eq!(first_difference(&out[0], &base[0]), None, "{form:?}: left"); + assert_ne!( + base[0][0].to_bits(), + base[0][1_500].to_bits(), + "the ride moves" + ); + assert_eq!( + out[1][1_500].to_bits(), + 0, + "{form:?}: the right lane's own mute" + ); + } +} diff --git a/docs/REALTIME_MEMORY.md b/docs/REALTIME_MEMORY.md index ab0a6cee6..96f95581d 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"). 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 +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 and mute automation is a compiled table in the plan (#1646, #1647, #1651; 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 71552890c..bdbbc9bef 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -287,7 +287,7 @@ 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 fader row renders; every other row is inert today.** An entry on a strip's fader +**The fader and mute rows render; 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 @@ -297,8 +297,14 @@ the fader. One exception: while a mute or unmute ramp holds the lane's gain, the 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, +quantum or on where a seek landed. An entry on a strip's mute (`builtins` row 6, `step` only, +values `0` and `1`) is compiled into the same table after every fader entry (#1651): the lane mutes +and unmutes at each step's exact sample over the session's mute ramp length +(`control_smoothing.mute_ms`), and the mute the stage renders is the curve's OR the lane's other +mute terms (its own static mute, any reaching VCA's mute, and solo), so a hand mute closes an open +curve and a hand unmute leaves a closed curve closed. Only a change of that OR moves the lane. A +session seek sets it exactly where the seek changes it, and a ramp toward a state the seek leaves +unchanged finishes. 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 diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 892c0cb7f..96cc48ee9 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -2762,7 +2762,7 @@ 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. +/// a mute entry (row 6, which renders since #1651 T3a) rebuilds alike 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 @@ -2849,10 +2849,10 @@ fn a_fader_automation_edit_rebuilds_alike_on_both_hosts() { assert_eq!( b, Verdict::Applied { - path: EditPath::ModelOnly, + path: EditPath::Rebuild, revision: model.revision + 2 }, - "{rate} Hz: a mute entry is model-only" + "{rate} Hz: a mute entry rebuilds (#1651 T3a: the mute row renders)" ); 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 From 40845228517fd59b716351e82e45847a106f9688 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 07:47:08 +0000 Subject: [PATCH 09/23] Restore the power of the hold-release-at-discharge gate (#1651, root check) a_live_write_that_frees_a_held_lane_releases_its_hold had been green under its own mutation (no release) since the D1.2 removal: the drain gives the cell no hold. It now puts an offsets write under a long unmute, so the cell holds and owes a move, and frees the lane later. It is red under no-release and under release-only-when-nothing-owed. The gap of a free write that keeps the owed flag moves to #1711. Co-Authored-By: Claude Opus 5.5 --- .../1651-render-stored-mute-automation.md | 13 +++++ .../tests/stored_fader_automation.rs | 48 ++++++++++++++----- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md index de0908aa2..0dab3ba5c 100644 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md @@ -498,3 +498,16 @@ PR #1712). keeps its prepared mute seed and `terms`, is not fresh, and lands no `Set`); the carry drain already routes an automated channel's mute value to the predecessor slot's `terms`, which composes onto the predecessor's stage before export. + +## Decision record: T1 gate power check (root, batch 18) + +- Each batch 17 mutation was applied again at `0e078aad8`. The nothing-owed gate (P1b at q37) is red + under release-only-when-owed and under no-release. The F1 gate is red when the `held_through` reset is + deleted, and its rewritten ramping form is red alone. +- `a_live_write_that_frees_a_held_lane_releases_its_hold` was green under no-release in its T1 form and + in its pre-T1 form. It went void at the D1.2 removal (the drain gives the cell no hold), not at T1. + The fix puts an offsets write at 3,200 under an 8,000-sample unmute, so the cell holds and owes a move; + the lane is then freed at 6,400. The gate is now red under no-release and under + release-only-when-nothing-owed, and green on the code. +- No test catches a free write that keeps the owed flag (deleting `self.owed = false` stays green). + Root moved that gap to #1711 (batch 19). diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index c368443b3..ddb1ece5e 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -1893,6 +1893,28 @@ fn live_fader(rig: &mut Rig, lanes: builtins::BuiltinLaneSelector, db: f32, wind }); } +/// Write a live offsets record `S = offsets_sum` (ramp `window`) on both channels of track 0, +/// applied at the next block entry. +fn live_offsets(rig: &mut Rig, offsets_sum: f64, 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| { + for channel in 0..2 { + handles.strip_controls[0].fader.write_offsets( + stamp, + channel, + builtins_compiler::FaderOffsetsRecord { + offsets_sum, + 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] { @@ -2037,29 +2059,33 @@ fn fade_from(from: f32, end: u64) -> Vec { /// 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 fader write -/// of `-10` dB over window 0 at 3,200 ends it there (a second unmute would not: a mute that repeats -/// the lane's state changes nothing, #1651 batch 13b). 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 +/// fade from `-30` to `0` dB; a live unmute of 8,000 samples is drained at 0, an offsets write +/// `S = -6` dB (ramp 200) at 3,200 under that ramp (the held branch of the offsets jump: the cell +/// forgets its target and holds to 7,999; the fade's grid events after it leave a move owed), and +/// a live fader write of `-10` dB over window 0 at 6,400 ends the ramp (a second unmute would not: +/// a mute that repeats the lane's state changes nothing, #1651 batch 13b). From 6,400 the lane +/// follows the composed curve: at each completion sample `64k + 63` it equals +/// `checked_fader_gain(vca_compose_db(curve, S))` 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. +/// live write left while the curve moves, until the hold's last grid point), or if the release is +/// made only when nothing is owed. #[test] fn a_live_write_that_frees_a_held_lane_releases_its_hold() { - const FRAMES: u64 = 4_800; + const FRAMES: u64 = 8_000; 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_fader(rig, builtins::BuiltinLaneSelector::Both, -10.0, 0), + 0 => live_mute(rig, builtins::BuiltinLaneSelector::Both, false, 8_000), + 3_200 => live_offsets(rig, -6.0, 200), + 6_400 => live_fader(rig, builtins::BuiltinLaneSelector::Both, -10.0, 0), _ => {} }); for plane in &out { - for t in (3_200 + 63..FRAMES).step_by(64) { + for t in (6_400 + 63..FRAMES).step_by(64) { assert_eq!( plane[t as usize].to_bits(), - gain(curve(&entry, t as i64)).to_bits(), + composed(curve(&entry, t as i64), -6.0).to_bits(), "completion sample {t}" ); } From 103e1e5b2e25a2426c0bf87bb54a7997cf36b7b7 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 08:13:43 +0000 Subject: [PATCH 10/23] Cut the fader piece at each in-block mute event (#1651 T3b) A lane with fader and mute automation renders each fader channel's block in pieces that end at the D1.6 cut at E, at the channel's next mute event, or at the block end. At a pushed SetMute the next piece takes the stage's hold: muted holds to the block end; an unmute over M sets E = o + M (a cut inside the block, due at entry at the block end, read from the stage past it). Fader events at an offset go first, under the old hold. One driver serves both bank forms, the fused fallback and the scalar oracle. Gate 2 and the in-block hold cases (E inside, at, past the block end; unmute and re-mute in one block; offsets 0 and q-1) are red under the named mutations. Fader-only bits unchanged; pcm_digest unchanged; realtime policy 161. Co-Authored-By: Claude Opus 5.5 --- .../1651-render-stored-mute-automation.md | 57 ++ crates/builtins-compiler/src/lib.rs | 527 +++++++++++------- .../host-core/tests/stored_mute_automation.rs | 347 +++++++++++- 3 files changed, 729 insertions(+), 202 deletions(-) diff --git a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md index 0dab3ba5c..6e527674b 100644 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md @@ -511,3 +511,60 @@ PR #1712). release-only-when-nothing-owed, and green on the code. - No test catches a free write that keeps the owed flag (deleting `self.owed = false` stays green). Root moved that gap to #1711 (batch 19). + +## Decision record: T3b (batch 18) + +- **Shipped (the in-block fader cut at mute events).** In the one `FaderEventDriver` + (`crates/builtins-compiler/src/lib.rs`), so the Simd8 and Simd4 banks, the fused fader-matrix + pair's fallback and the scalar oracle share it (R17-4). A fader channel's block is rendered in + pieces: each piece ends at the D1.6 cut `E`, at the offset of the same lane and channel's next + mute event, or at the block's last sample (`FaderCellCursor::piece_last`), and the next piece + starts at `+ 1` (`open`, `piece_span`). A piece is consumed to its end before the next starts, + so the cell's state advances only through events the stage was given (#1647 constraint 2). At + each offset: the fader cursors push first (a gain operation at `o` keeps the old hold, gain + before mute), then the mute cursors, then each fader channel whose piece ended at its mute + event takes the hold the pushed `SetMute` gives (`rehold`) and opens its next piece: muted -> + held to the block's end (`held_through`); unmuted over `M` -> `E = o + M`: inside the block a + cut (owed events below `E` move there through D1.6), at the block's end `due_at_entry`, past it + the next block finds the hold from the stage; unmuted exactly (`M = 0`) -> free, nothing owed + (the stage takes the remembered gain, the cell's committed target). A mute event that changes + nothing (not pushed) keeps the old hold. An older cut ahead is dropped by any pushed `SetMute`. + Mute cursors are now built before a lane's fader cursors, so each fader channel's first piece is + cut at its first mute event. `HoldEnd` loses its `rest` span (the next piece is built at the cut). +- **Departure from the handoff's text.** "Start the next piece at `o + 1`" is applied at every + mute event of the channel, pushed or not: the piece must end there because the driver pulls one + event ahead, and the hold is known only after the mute cursor composes `curve || terms`. A + non-pushed event splits the piece with the hold unchanged, which moves no event (the same split + the D1.6 cut makes). +- **Gates (all pass).** `crates/host-core/tests/stored_mute_automation.rs`, every form (Simd8 on + AVX2, Simd4, scalar oracle; no live control, live controls, between render calls), quanta 128, + 100, 64 and 37; the driver's pushed fader events and both planes are compared with a model of + the rule written apart from the driver (`compose_model`: the fader-only plan's events, each + remembered by the stage while held, the owed move at `E` over `J` to the curve at `E + J - 1`), + driven into the stage directly with #1638's operations. + - Gate 2: `a_fader_ride_under_a_curve_mute_is_remembered_and_moves_at_the_unmute_end` (grid + events inside a curve mute and inside its unmute ramp, the move at `E = 2,480`). Red if the + move at `E` is missing (mutation `moving = false`: first difference at 2,480). Green on the base driver: its `E` lies in a later block, which batch 17's block-entry rule already handles. + - Block boundaries, `M = 48` (and 96): `a_hold_that_starts_and_ends_inside_a_block_moves_at_its_end`: + `E` inside the unmute's block, `E` exactly at a block end, `E` in the next block, an unmute and + a re-mute in the same block (after `E`: two moves; inside the unmute ramp: the first `E` + dropped, the owed move kept through the mute), a mute at offset 0 with an unmute at `q - 1`, + a mute at `q - 1` with an unmute at 0, and fader steps exactly at the mute and the unmute + samples. Red checks (each a temporary mutation, then reverted): no rehold at the mute event + (base behaviour, first difference at 1,348); `E` off by one either way (bits differ at 1,348; + events at 1,349 / 1,347); owed events below `E` dropped (`free_from = o`: bits at 1,348); + `due_at_entry` not set at an `E` on the block's end (bits at 1,408); a re-mute that keeps the + old cut (the pushed events: a move at 1,396 under the mute). The base driver (`ce32c1b1f`) is + red at 1,348. + - Batch 17's gates stay green: `stored_fader_automation.rs` (30), `stored_mute_automation.rs`'s + T3a tests, capi `live_tests`, host-web parity. +- **Measured.** One-time PR evidence (not committed): a fader-only probe (three tracks: a fade, a + step and fade, two steps; live mutes with ramps and with `0`; a seek; every form; quanta 128, 100 + and 37) renders the same bits at base `ce32c1b1f` and head (FNV `7caf69e2bae5afcd` both). + `audit capi` `pcm_digest` `bffc21e857b5e134`, allocations 0. `realtime_policy`: ok, 161 marked regions (unchanged). `lane_slot_bytes` 392 unchanged (the slot is not + touched; the new fields live in the driver on the render stack). +- **Phase-2 placeholder.** A cell hold set by an offsets jump on a lane held by a mute ramp + (`AutomatedFaderCell::offsets_jump`: until the stage's ramp end at the block entry) is not + shortened when a curve unmute inside that block ends the hold earlier; grid events between the + new `E` and the old ramp end are pushed as held (`RememberGain` on a free stage) until the cell's + next free event. Needs a VCA write during a mute ramp and a curve unmute inside it. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 4a50dfb80..6f1a7e2a4 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -1370,19 +1370,23 @@ struct ChannelHold { fader_gain: Option, } -/// 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`. +/// One channel's block cut at the end `E` of a held ramp (#1647 D1.6, constraint 2): the piece +/// that holds `E` ends there, and the next 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). +/// +/// The block is rendered in pieces, each a span of the cell's events: a piece ends at the cut at +/// `E` (D1.6), at the offset of the channel's next mute event (#1651 T3b: a mute event changes +/// where the stage holds the gain, so the piece after it is cut where that hold ends), or at the +/// block's last sample. A piece's events are consumed to its end before the next starts, so the +/// cell's state advances only through events the stage was given (#1647 constraint 2). struct FaderCellCursor<'a> { /// The owner's lane (the bank lane; `0` on the scalar oracle). lane: usize, @@ -1390,22 +1394,32 @@ struct FaderCellCursor<'a> { channel: usize, /// `S`, the offset sum every event's value is composed with (#1646 D1). offsets_sum: f64, - /// The cell's program and the block's jump length, to build the second piece. + /// The cell's program and the block's jump length, to build the later pieces. program: automation::CellProgram<'a>, jump: u32, - /// The current piece's events; `None` once the block is spent. + /// The current piece's events, spent or not: it holds the cell's state for the next piece. + /// `None` only after a refusal (unreachable). events: Option>, /// The block offset of the current piece's first sample. base: u32, + /// The block offset of the current piece's last sample. + last: 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). + /// is only remembered (`0`: free all block; the block length: held all block). A mute event + /// pushed on the channel sets it again ([`Self::rehold`]). free_from: u32, /// The cut at the held ramp's end, while it is ahead. end: Option, + /// The offset of the channel's next mute event, while one is ahead (#1651 T3b). + mute_at: Option, + /// The block's node-time mapping: offset 0's node time and the seek step, and its length. + start: i64, + step: Option<(u32, i64)>, + frames: u32, } impl FaderCellCursor<'_> { @@ -1423,57 +1437,153 @@ impl FaderCellCursor<'_> { } /// 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). + /// pieces before it are spent (a piece never lies past `E`, so no event is pulled 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. 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. + /// The last offset of a piece that starts at `from`: the cut at `E` or the channel's next + /// mute event, whichever comes first, else the block's last sample. + fn piece_last(&self, from: u32) -> u32 { + [self.end.map(|end| end.at), self.mute_at] + .into_iter() + .flatten() + .filter(|&at| at >= from) + .fold(self.frames - 1, u32::min) + } + + /// End the piece at the cut. 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. The next piece is + /// opened by the caller ([`Self::open`]). 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 mut state = self.events.take()?.into_state(); - let mut landing = None; - if moving { - 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(); - } - // 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"), - } + if !moving { + return None; + } + // 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. + let Ok(span) = automation::NodeSpan::new(end.node, Some((0, end.node)), 1) else { + debug_assert!(false, "a one-sample span with its step at 0 is valid"); + return None; + }; + let state = self.events.take()?.into_state(); + let mut events = automation::CellEvents::new( + self.program, + state, + span, + automation::SeekMode::Ramp, + self.jump, + ); + let landing = events.next(); + self.events = Some(events); + landing + } + + /// Start the next piece at block offset `from` (the current piece is spent), through + /// [`Self::piece_last`]. `false` when there is none: `from` is past the block, or a refusal + /// (unreachable: the block's node times were checked against the `i64` edge, and a step kept + /// inside the piece lies inside it), which leaves the cell's state where it was. + fn open(&mut self, from: u32) -> bool { + if from >= self.frames { + return false; } - 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(); + let last = self.piece_last(from); + let Some(span) = piece_span(self.start, self.step, from, last) else { + debug_assert!(false, "a piece of a checked block is a valid span"); + return false; + }; + let Some(events) = self.events.take() else { + return false; + }; + self.base = from; + self.last = last; + self.events = Some(automation::CellEvents::new( + self.program, + events.into_state(), + span, + controls::FADER_CELL_RULE.seek, + self.jump, + )); + self.pull(); + true + } + + /// #1651 T3b: the hold a `SetMute { muted, samples }` pushed at offset `at` gives the channel + /// from `at + 1` on (the gain operation at `at` kept the old hold: gain before mute). Muted + /// (settled or in a ramp toward `0.0`): held to the block's end, and through it. Unmuted over + /// `M = samples`: held until `E = at + M`, the first offset at which the stage reports the + /// gain free; an `E` inside the block is the cut where an owed move lands (D1.6), an `E` at + /// the block's end makes the move due at the next block's first sample, and a later `E` is + /// the next block's to find from the stage. Unmuted exactly (`M = 0`): the stage takes the + /// remembered gain, the cell's committed target, at `at`, so the gain is free and nothing is + /// owed. An older cut ahead is dropped: the stage's hold no longer ends there. + fn rehold(&mut self, at: u32, muted: bool, samples: u32) { + self.end = None; + let frames = self.frames; + let obligation = &mut *self.obligation; + obligation.due_at_entry = false; + if muted { + self.free_from = frames; + obligation.held_through = true; + return; + } + if samples == 0 { + self.free_from = 0; + obligation.owed = false; + obligation.held_through = false; + return; + } + match u32::try_from(u64::from(at) + u64::from(samples)) { + Ok(end) if end < frames => { + self.free_from = end; + self.end = hold_end(self.start, self.step, end); + // Unreachable: the block's node times were checked against the `i64` edge. In + // release no cut is set: the lane holds the gain the unmute ramp aimed for. + debug_assert!( + self.end.is_some(), + "an end inside a checked block has a node time" + ); + obligation.held_through = false; + } + end => { + self.free_from = frames; + obligation.due_at_entry = end == Ok(frames); + obligation.held_through = true; + } } - landing } } +/// The span of the block offsets `[from, last]` of a block whose offset 0 is node time `start`, +/// with seek step `step` (kept, rebased, when it lies inside the piece); `None` past the `i64` +/// range (unreachable: the caller checked the block's span). +fn piece_span( + start: i64, + step: Option<(u32, i64)>, + from: u32, + last: u32, +) -> Option { + let frames = last.checked_sub(from)?.checked_add(1)?; + let inside = step.filter(|&(offset, _)| offset >= from && offset <= last); + let span = if from == 0 { + automation::NodeSpan::new(start, inside, frames) + } else { + automation::NodeSpan::new( + span_time(start, step, u64::from(from))?, + inside.map(|(offset, timeline)| (offset - from, timeline)), + frames, + ) + }; + span.ok() +} + /// 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. /// @@ -1518,6 +1628,9 @@ struct MuteCellCursor<'a> { events: automation::CellEvents<'a, 'a>, /// The next event, its offset in the block. next: Option, + /// The `SetMute { muted, samples }` pushed at the offset being applied, if any: the lane's + /// fader channel takes its hold from it (#1651 T3b). + pushed: Option<(bool, u32)>, } impl MuteCellCursor<'_> { @@ -1580,6 +1693,63 @@ impl<'a> FaderEventDriver<'a> { continue; } let program: &'a automation::Program = program; + // #1651 D1: the lane's mute cells, before its fader cells, so each fader channel's + // first piece is cut at its first mute event (T3b). A mute cell gives no grid event + // (a step curve is constant between its discontinuities), only its jumps over the + // block's mute length and a seek's exact landing; its first block lands a `Set` when + // the curve at the block's node time differs from its seed, as a fader cell's does + // (D2, departure 4). + let lane_mutes = mutes_used; + for (channel, cell) in mute.iter_mut().enumerate() { + let Some(AutomatedMuteCell { cell, state, terms }) = cell.as_mut() else { + continue; + }; + let refused = + || debug_assert!(false, "an automated mute 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 keeps its mute, no panic. + let Some(cell_program) = program.cell(*cell as usize) else { + refused(); + 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 && state.target().map(f32::to_bits) != Some(value.to_bits())) + .then_some((0, span.start)) + }); + let muted = hold(lane, channel).is_some_and(|hold| hold.muted); + // Unreachable refusal: the graph's step lies inside the block, and at most + // `MAXIMUM_FADER_CELLS` mute cursors are used (two channels per lane). In release + // the channel is skipped: it emits nothing and the lane keeps its mute. + let (Ok(node), Some(slot)) = ( + automation::NodeSpan::new(span.start, step, frames), + driver.mutes.get_mut(mutes_used), + ) else { + refused(); + continue; + }; + let mut cursor = MuteCellCursor { + lane, + channel, + terms: *terms, + muted, + events: automation::CellEvents::new( + cell_program, + state, + node, + controls::MUTE_CELL_RULE.seek, + mute_jump, + ), + next: None, + pushed: None, + }; + cursor.pull(); + *slot = Some(cursor); + mutes_used += 1; + } for (channel, cell) in channels.iter_mut().enumerate() { let Some(AutomatedFaderCell { cell, @@ -1639,43 +1809,56 @@ impl<'a> FaderEventDriver<'a> { // 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)); - 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), - }; - // 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), - matches!(end, Some(None)), - ) else { + let end = at.map(|at| hold_end(span.start, step, at)); + // The channel's first mute event (#1651 T3b): the first piece ends there. + let mute_at = driver + .mutes + .get(lane_mutes..mutes_used) + .unwrap_or(&[]) + .iter() + .flatten() + .find(|mute| mute.channel == channel) + .and_then(|mute| mute.next.map(|event| event.offset)); + // Unreachable refusal: 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 (Some(slot), false) = (driver.cursors.get_mut(used), matches!(end, Some(None))) + else { refused(); continue; }; - let end = end.flatten(); - let (events, landing, base) = match long_landing { + let mut cursor = FaderCellCursor { + lane, + channel, + offsets_sum: *offsets_sum, + program: cell_program, + jump, + events: None, + base: 0, + last: 0, + next: None, + obligation, + free_from, + end: end.flatten(), + mute_at, + start: span.start, + step, + frames, + }; + 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 { + Some(length) if cursor.end.is_none() => { + // Unreachable: a step at 0 lies inside one sample. In release the + // channel is skipped: it emits nothing and the lane holds its gain. + let Ok(one) = + automation::NodeSpan::new(span.start, Some((0, span.start)), 1) + else { refused(); continue; }; @@ -1686,105 +1869,31 @@ impl<'a> FaderEventDriver<'a> { 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) + cursor.next = landing_events.next(); + cursor.events = Some(landing_events); } - _ => ( - Some(automation::CellEvents::new( + _ => { + let last = cursor.piece_last(0); + // Unreachable: a step kept inside the piece lies inside it. In release + // the channel is skipped: it emits nothing and the lane holds its gain. + let Some(node) = piece_span(span.start, step, 0, last) else { + refused(); + continue; + }; + cursor.last = last; + cursor.events = 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, - base, - next: None, - obligation, - free_from, - end, - }; - if landing.is_some() { - cursor.next = landing; - } else { - cursor.pull(); + )); + cursor.pull(); + } } *slot = Some(cursor); used += 1; } - // #1651 D1: the lane's mute cells. A mute cell gives no grid event (a step curve is - // constant between its discontinuities), only its jumps over the block's mute length - // and a seek's exact landing; its first block lands a `Set` when the curve at the - // block's node time differs from its seed, as a fader cell's does (D2, departure 4). - for (channel, cell) in mute.iter_mut().enumerate() { - let Some(AutomatedMuteCell { cell, state, terms }) = cell.as_mut() else { - continue; - }; - let refused = - || debug_assert!(false, "an automated mute 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 keeps its mute, no panic. - let Some(cell_program) = program.cell(*cell as usize) else { - refused(); - 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 && state.target().map(f32::to_bits) != Some(value.to_bits())) - .then_some((0, span.start)) - }); - let muted = hold(lane, channel).is_some_and(|hold| hold.muted); - // Unreachable refusal: the graph's step lies inside the block, and at most - // `MAXIMUM_FADER_CELLS` mute cursors are used (two channels per lane). In release - // the channel is skipped: it emits nothing and the lane keeps its mute. - let (Ok(node), Some(slot)) = ( - automation::NodeSpan::new(span.start, step, frames), - driver.mutes.get_mut(mutes_used), - ) else { - refused(); - continue; - }; - let mut cursor = MuteCellCursor { - lane, - channel, - terms: *terms, - muted, - events: automation::CellEvents::new( - cell_program, - state, - node, - controls::MUTE_CELL_RULE.seek, - mute_jump, - ), - next: None, - }; - cursor.pull(); - *slot = Some(cursor); - mutes_used += 1; - } } driver } @@ -1820,14 +1929,17 @@ impl<'a> FaderEventDriver<'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(); - } - if let Some(end) = cursor.end { - let _ = cursor.cut(end, false); + cursor.mute_at = None; + loop { while cursor.next.is_some() { cursor.pull(); } + if let Some(end) = cursor.end.filter(|end| end.at <= cursor.last) { + let _ = cursor.cut(end, false); + } + if !cursor.open(cursor.last.saturating_add(1)) { + break; + } } } for cursor in self.mutes.iter_mut().flatten() { @@ -1974,22 +2086,11 @@ fn settle_adoption( differs.then_some(length) } -/// 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 { +/// The cut at offset `at` of a block whose offset 0 is node time `start`, with seek step `step`; +/// `None` past the `i64` range (unreachable: the caller checked the block's span). +fn hold_end(start: i64, step: Option<(u32, i64)>, at: 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 }) + Some(HoldEnd { at, node }) } impl builtins::FaderOperationSource for FaderEventDriver<'_> { @@ -2055,24 +2156,28 @@ impl builtins::FaderOperationSource for FaderEventDriver<'_> { } cursor.pull(); } - let Some(end) = end else { - continue; - }; - if set_at_end { - cursor.obligation.owed = false; - } - let landing = cursor.cut(end, moving); - if moving { - 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"), + if let Some(end) = end { + if set_at_end { + cursor.obligation.owed = false; + } + let landing = cursor.cut(end, moving); + if moving { + cursor.obligation.owed = false; + match landing { + Some(landing) => push(cursor, landing), + // Unreachable: a cursor's events are `Some` but after a refusal, 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"), + } } } + // The piece ends here: the next starts at `offset + 1`, unless the channel's mute + // event at `offset` sets its hold first (below). + if cursor.last == offset && cursor.mute_at != Some(offset) { + let _ = cursor.open(offset.saturating_add(1)); + } } // #1651 D1: each mute cell's event at `offset` composes the stage mute `curve || terms`, // and only a change pushes `SetMute`: over the event's ramp (the block's mute jump @@ -2083,6 +2188,7 @@ impl builtins::FaderOperationSource for FaderEventDriver<'_> { continue; }; cursor.pull(); + cursor.pushed = None; let muted = curve_mutes(event.value) || cursor.terms; if muted == cursor.muted { continue; @@ -2103,6 +2209,9 @@ impl builtins::FaderOperationSource for FaderEventDriver<'_> { // is a populated member, and a mute operation carries no gain. In release the stage // keeps its mute, no panic. debug_assert!(pushed, "a stored mute event is refused at render"); + if pushed { + cursor.pushed = Some((muted, samples)); + } #[cfg(any(test, feature = "test-support"))] test_only_record_fader_event(TestOnlyFaderEvent { lane: cursor.lane, @@ -2115,6 +2224,26 @@ impl builtins::FaderOperationSource for FaderEventDriver<'_> { mute: true, }); } + // #1651 T3b: a fader channel whose piece ended at its mute event takes the hold the + // pushed `SetMute` gives (an unpushed one, which changed nothing, keeps the old hold), + // and its next piece starts at `offset + 1`, cut at the new `E` or at the channel's next + // mute event. Bounded: one search of at most `MAXIMUM_FADER_CELLS` mute cursors per + // fader channel with a mute event at `offset`. + for cursor in self.cursors.iter_mut().flatten() { + if cursor.mute_at != Some(offset) { + continue; + } + let mute = self + .mutes + .iter() + .flatten() + .find(|mute| mute.lane == cursor.lane && mute.channel == cursor.channel); + cursor.mute_at = mute.and_then(|mute| mute.next.map(|event| event.offset)); + if let Some((muted, samples)) = mute.and_then(|mute| mute.pushed) { + cursor.rehold(offset, muted, samples); + } + let _ = cursor.open(offset.saturating_add(1)); + } } } // REALTIME_POLICY_END diff --git a/crates/host-core/tests/stored_mute_automation.rs b/crates/host-core/tests/stored_mute_automation.rs index bbca16376..52186ab44 100644 --- a/crates/host-core/tests/stored_mute_automation.rs +++ b/crates/host-core/tests/stored_mute_automation.rs @@ -2,7 +2,10 @@ //! stage at their exact samples over the session mute length, through the one event driver, on //! every bank width and on the scalar oracle (R17-4); a seek that leaves the stage mute unchanged //! lets a ramp toward that state finish (root ruling R18-1); and a mute value the strip's live cell -//! carries is the lane's `terms`, which render ORs with the curve (OQ1 = C). +//! carries is the lane's `terms`, which render ORs with the curve (OQ1 = C). On a lane with fader +//! and mute automation, each mute event changes where the stage holds the gain, inside the block +//! (T3b): fader events inside the hold are only remembered, and the move the hold owes lands at +//! the unmute ramp's end `E` (#1647 D1.6). //! //! Every test prepares on a chosen backend, a `test-support` entry of host-core. #![cfg(feature = "test-support")] @@ -58,6 +61,13 @@ fn caps(model: &SessionModel) -> HostPrepareCaps { /// insert, no input filter, an identity matrix, each routed to the output, `control_smoothing` /// explicit (fader 5 ms, mute 10 ms). On a DC source of `1.0` a track's output is its fader gain. fn plain(tracks: usize, quantum: u32) -> SessionModel { + let model = plain_with(tracks, quantum, 10.0); + assert_eq!(controls::LiveRamps::for_session(&model).mute_samples, M); + model +} + +/// [`plain`] with the session mute length `mute_ms`. +fn plain_with(tracks: usize, quantum: u32, mute_ms: f32) -> SessionModel { let mut model = parse_session_json(NINE).expect("fixture parses"); model.automation.clear(); model.console.pre_insert.clear(); @@ -86,11 +96,10 @@ fn plain(tracks: usize, quantum: u32) -> SessionModel { model.sources[0].frames = 1 << 20; model.quantum_frames = quantum; model.control_smoothing = Some(ControlSmoothing { - mute_ms: 10.0, + mute_ms, fader_ms: 5.0, pan_ms: 5.0, }); - assert_eq!(controls::LiveRamps::for_session(&model).mute_samples, M); model } @@ -691,3 +700,335 @@ fn a_mute_entry_leaves_a_fader_ride_as_it_was() { ); } } + +/// The fader jump length `J` of every test here: `fader_ms` 5 at 48 kHz. +const J: u32 = 240; + +/// 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") +} + +/// A fader entry on both lanes of track `track`, from its `(shape, start, end, from dB, to dB)` +/// segments. +fn fader_entry( + model: &SessionModel, + track: usize, + segments: &[(AutomationShape, u64, u64, f32, 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: ParameterChannel::Both, + }, + segments: segments + .iter() + .map( + |&(shape, start_sample, end_sample, start_value, end_value)| AutomationSegment { + shape, + start_sample, + end_sample, + start_value, + end_value, + unit: ParameterUnit::Db, + }, + ) + .collect(), + offset: session::AutomationOffset::default(), + } +} + +/// Fader steps at each `(sample, dB)` of `steps`, from `from` dB. +fn fader_steps(from: f32, steps: &[(u64, f32)]) -> Vec<(AutomationShape, u64, u64, f32, f32)> { + let mut at = (0_u64, from); + steps + .iter() + .map(|&(sample, value)| { + let segment = (AutomationShape::Step, at.0, sample, at.1, value); + at = (sample, value); + segment + }) + .collect() +} + +/// One left-lane fader event the driver pushed: `(sample, kind, held, value, ramp)`. +type FaderEvent = (u64, automation::EventKind, bool, f32, u32); + +/// The left-lane fader events the driver pushed since the last take. +fn fader_events() -> Vec { + let (events, overflow) = builtins_compiler::test_only_take_fader_events(); + assert!(!overflow); + events + .iter() + .filter(|event| !event.mute && event.channel == 0) + .map(|event| { + ( + event.sample, + event.kind, + event.held, + event.value, + event.ramp, + ) + }) + .collect() +} + +/// The stage operation of one pushed fader event (#1638 D1). +fn operation(event: &FaderEvent) -> builtins::FaderOperation { + let &(_, kind, held, value, ramp) = event; + let gain = gain(value); + if held { + builtins::FaderOperation::RememberGain { gain } + } else if kind == automation::EventKind::Set { + builtins::FaderOperation::SetGain { gain } + } else { + builtins::FaderOperation::RetargetGain { + gain, + samples: ramp, + } + } +} + +/// The stage driven directly from `start` dB with `operations` (in order at each sample). +fn reference_from( + start: f32, + operations: &[(u64, builtins::FaderOperation)], + frames: u64, +) -> Vec { + let mut all = vec![(0, builtins::FaderOperation::SetGain { gain: gain(start) })]; + all.extend_from_slice(operations); + reference(&all, frames) +} + +/// The spec's model of a lane with a fader ride and curve mute steps (#1647 D1.6, #1651 T3b), +/// written apart from the driver: the ride's own events `ride` (the events a fader-only plan of +/// the same curve pushes) and each mute step `(sample, muted)` over `mute` samples. At each sample +/// the gain operation comes first, under the hold before the sample's mute step. An event while +/// the gain is held (muted, or inside an unmute ramp `[u, u + M)`) is only remembered by the +/// stage and owes a move; at `E = u + M` an owed move lands in place of the ride's event there: +/// a `Jump` over `J` to the curve at `E + J - 1` (`curve`, flat there in every case). Returns the +/// events the driver must push, the stage operations of the whole lane and the number of moves. +fn compose_model( + ride: &[FaderEvent], + steps: &[(u64, bool)], + mute: u32, + curve: impl Fn(i64) -> f32, +) -> (Vec, Vec<(u64, builtins::FaderOperation)>, usize) { + let mut times: Vec = ride + .iter() + .map(|event| event.0) + .chain(steps.iter().map(|step| step.0)) + .chain(steps.iter().map(|step| step.0 + u64::from(mute))) + .collect(); + times.sort_unstable(); + times.dedup(); + let mut events = Vec::new(); + let mut operations = Vec::new(); + let mut free_from = 0_u64; + let mut end: Option = None; + let mut owed = false; + let mut moves = 0; + for t in times { + let own = ride.iter().find(|event| event.0 == t); + if end == Some(t) && owed { + let value = curve(t as i64 + i64::from(J) - 1); + let landing = (t, automation::EventKind::Jump, false, value, J); + operations.push((t, operation(&landing))); + events.push(landing); + owed = false; + moves += 1; + } else if let Some(event) = own { + // A move changes no later discontinuity jump of the ride; its grid is flat there. + assert!( + moves == 0 || event.1 == automation::EventKind::Jump, + "the ride is flat after the move, at {t}" + ); + operations.push((t, operation(event))); + events.push(*event); + if t < free_from { + owed = true; + } + } + if end == Some(t) { + end = None; + } + if let Some(&(_, muted)) = steps.iter().find(|step| step.0 == t) { + operations.push((t, set_mute(muted, mute))); + if muted { + free_from = u64::MAX; + end = None; + } else { + free_from = t + u64::from(mute); + end = Some(free_from); + } + } + } + (events, operations, moves) +} + +/// Run one fader-and-mute case (#1651 gate 2, T3b) on every form at quanta 128, 100, 64 and 37: +/// track 0 has the fader entry `fader` and a mute entry with steps `steps` (alternating from +/// unmuted) under a session mute length of `mute_ms`. The driver pushes exactly the events of +/// [`compose_model`] (the fader-only plan's events, with the owed move at `E`), and both planes are +/// bit for bit the stage driven directly with that model's operations. Returns how many moves +/// the model lands, so a case can say it reaches the move. +fn compose_case( + label: &str, + fader: &[(AutomationShape, u64, u64, f32, f32)], + steps: &[u64], + mute_ms: f32, + frames: u64, +) -> usize { + let mut moves = 0; + for quantum in [128, 100, 64, 37] { + let mut ride_only = plain_with(1, quantum, mute_ms); + let mute = controls::LiveRamps::for_session(&ride_only).mute_samples; + let entry = fader_entry(&ride_only, 0, fader); + let segments = controls::stored_segments(&entry); + let start = automation::value_at(&segments, 0); + ride_only.automation = vec![entry]; + let mut model = ride_only.clone(); + let mute_steps: Vec<(u64, f32)> = steps + .iter() + .enumerate() + .map(|(index, &sample)| (sample, if index % 2 == 0 { 1.0 } else { 0.0 })) + .collect(); + model.automation.push(mute_entry( + &model, + 0, + ParameterChannel::Both, + 0.0, + &mute_steps, + )); + let model_steps: Vec<(u64, bool)> = mute_steps + .iter() + .map(|&(sample, value)| (sample, value != 0.0)) + .collect(); + for form in forms() { + let tag = format!("{label} q={quantum} {form:?}"); + let _ = builtins_compiler::test_only_take_fader_events(); + let _ = Rig::new(&ride_only, form).run(frames); + let ride = fader_events(); + let (events, operations, landed) = compose_model(&ride, &model_steps, mute, |t| { + automation::value_at(&segments, t) + }); + let expected = reference_from(start, &operations, frames); + let (out, _) = Rig::new(&model, form).run(frames); + let pushed = fader_events(); + for plane in &out { + assert_eq!(first_difference(plane, &expected), None, "{tag}"); + } + assert_eq!(pushed, events, "{tag}: the pushed fader events"); + moves = landed; + } + } + moves +} + +/// #1651 gate 2 (T3b): a fader ride and curve mute steps on one lane. The ride moves while the +/// curve holds the lane muted ([1,000, 2,000): grid events on [1,200, 1,600)) and while its unmute +/// ramp runs ([2,000, 2,480): grid events on [2,100, 2,300)), then rests at `-6` dB. The events +/// inside the mute and the unmute ramp are only remembered; the move lands at `E = 2,480` over +/// `J` to `-6` dB; every sample is the stage driven directly with those operations, on every form +/// at quanta 128, 100, 64 and 37. +/// +/// Test value: red if a fader event restarts or cuts the mute ramp, if the move at `E` is missing +/// or late (the lane would rest at the `-10` dB the unmute ramp aimed for), or if the ride's +/// events change under the mute (the cursor must push the fader-only plan's events). +#[test] +fn a_fader_ride_under_a_curve_mute_is_remembered_and_moves_at_the_unmute_end() { + let moves = compose_case( + "ride", + &[ + (AutomationShape::Linear, 0, 1_200, -20.0, -20.0), + (AutomationShape::Linear, 1_200, 1_600, -20.0, -10.0), + (AutomationShape::Linear, 1_600, 2_100, -10.0, -10.0), + (AutomationShape::Linear, 2_100, 2_300, -10.0, -6.0), + ], + &[1_000, 2_000], + 10.0, + 3_200, + ); + assert_eq!(moves, 1, "the case reaches the move at E"); +} + +/// #1651 T3b, holds that start and end inside a block: a session mute length of 1 ms (`M = 48`) +/// so the unmute ramp's end `E` falls inside blocks. Each case puts a fader step inside the +/// unmute ramp, which owes the move at `E`; the cases are placed against the 128-sample block +/// grid (and run at quanta 128, 100, 64 and 37 on every form): +/// - `E` inside the unmute's block (unmute 1,300, `E` 1,348, fader step 1,320); +/// - `E` exactly at a block's end (unmute 1,360, `E` 1,408 = the next block's first sample); +/// - `E` in the next block (unmute 1,390, `E` 1,438, step 1,400); +/// - an unmute and a re-mute in the same block, after `E` (unmute 1,290, `E` 1,338, re-mute +/// 1,354, last unmute 1,600, steps 1,310 and 1,620: two moves), and, with `M = 96`, inside the +/// unmute ramp (unmute 1,300, re-mute 1,364, last unmute 1,600, steps 1,320 and 1,650): the +/// first `E` (1,396) is dropped, the owed move is kept through the mute (constraint 4) and +/// lands at 1,696; +/// - a mute at a block's offset 0 (1,280) and an unmute at offset `q - 1` (1,535); +/// - a mute at offset `q - 1` (1,407) and an unmute at offset 0 (1,664); +/// - fader steps exactly at the mute (1,000: a free retarget, then the mute) and at the unmute +/// (1,300: remembered under the old hold, gain before mute, so it owes the move at 1,348). +/// +/// Test value: red if the fader piece is not cut at the mute event (events after an in-block mute +/// would not owe the move, or an in-block unmute's `E` would get no cut), if `E` is off by one +/// (the move would land on a held stage and be only remembered, or a sample late), if an owed +/// event below `E` is dropped, if a re-mute keeps the old cut, or if the hold at a block's end +/// does not carry into the next block (`due_at_entry`, `held_through`). +#[test] +fn a_hold_that_starts_and_ends_inside_a_block_moves_at_its_end() { + const MS: f32 = 1.0; + assert_eq!( + controls::LiveRamps::for_session(&plain_with(1, 128, MS)).mute_samples, + 48 + ); + // `(label, fader steps (sample, dB), mute step samples)`. + type Case = (&'static str, Vec<(u64, f32)>, &'static [u64]); + let cases: [Case; 7] = [ + ("E inside the block", vec![(1_320, -8.0)], &[1_000, 1_300]), + ("E at the block end", vec![(1_380, -8.0)], &[1_000, 1_360]), + ("E in the next block", vec![(1_400, -8.0)], &[1_000, 1_390]), + ( + "unmute and re-mute after E", + vec![(1_310, -8.0), (1_620, -3.0)], + &[1_000, 1_290, 1_354, 1_600], + ), + ( + "mute at 0, unmute at q-1", + vec![(1_290, -8.0), (1_560, -3.0)], + &[1_280, 1_535], + ), + ( + "mute at q-1, unmute at 0", + vec![(1_450, -8.0), (1_700, -3.0)], + &[1_407, 1_664], + ), + ( + "steps at the mute and the unmute", + vec![(1_000, -8.0), (1_300, -3.0)], + &[1_000, 1_300], + ), + ]; + for (label, steps, mutes) in cases { + let moves = compose_case(label, &fader_steps(-20.0, &steps), mutes, MS, 2_560); + let expected = if label == "unmute and re-mute after E" { + 2 + } else { + 1 + }; + assert_eq!(moves, expected, "{label}: the case reaches the move at E"); + } + let moves = compose_case( + "re-mute inside the unmute ramp", + &fader_steps(-20.0, &[(1_320, -8.0), (1_650, -3.0)]), + &[1_000, 1_300, 1_364, 1_600], + 2.0, + 2_560, + ); + assert_eq!( + moves, 1, + "the dropped cut lands no move; the last unmute's does" + ); +} From 977d6969c18f98157976a720b0f18f1616f02761 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 08:30:01 +0000 Subject: [PATCH 11/23] Fold the U2a review into the following route op (#1652, fold 1) - MAJOR-1: a following route that opens inside a block records its activation offset; its destination reduces [0, o) without it, so no signed zero reaches the bus. - R18-5 (MAJOR-2, MINOR-1): per-column ramps. One indexed ramp per source column; only a column whose target bits change retargets, so an open column's live move keeps its length across a carry and per-lane curve steps each end with the strip's mute length. One new lane kernel entry, route_mix_column_ramps_block, with the six-word kernel's vector shape; with equal ramps it is that kernel bit for bit. - MAJOR-3: a carried first block applies the join's record with the heard curve first, then the successor's curve jumps over J; a six-word successor does the same and then reopens. - MINOR-2: adopt_predecessor finishes a pending route carry first. - MINOR-3: RouteCarryStep is back to 8 B; following-route checks live in a side table sized for those moves only. Realtime policy 166 -> 167. Co-Authored-By: Claude Opus 5.5 --- ...mated-mute-on-following-sends-in-render.md | 100 +- crates/graph/src/lib.rs | 153 +- crates/graph/src/runtime.rs | 1534 +++++++++++++---- crates/host-core/src/prepare.rs | 21 +- crates/lane/src/kernels.rs | 207 +++ crates/lane/tests/route_ramp.rs | 204 ++- 6 files changed, 1898 insertions(+), 321 deletions(-) diff --git a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md index 9ccf712bc..91c4b3328 100644 --- a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md +++ b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md @@ -423,6 +423,9 @@ Tranche R2: the following route op in render. All in `crates/graph`; no attach, it not muted-settled. An inactive block commits the copies. No change in such a block retargets, because every silencing target is `[+0.0; 4]`. A route that unmutes at `o` mixes the whole block; `[0, o)` mixes the silent coefficients, which can contribute only a signed zero. + *Corrected by U2a fold 1 (MAJOR-1):* that signed zero reached the destination (`-0.0` where the + block split at `o` gives `+0.0`). The route op now reports its activation offset `o`, and the + destination leaves the route out of `[0, o)`; see the fold record below. - **Choices.** (1) Events of both lanes at one offset compose once; the length is 0 when one of them is a `Set` (a seek is exact). (2) The `mute` flag follows every composition; only the coefficients' bits decide a retarget. (3) The jump length is clamped to @@ -431,7 +434,11 @@ Tranche R2: the following route op in render. All in `crates/graph`; no attach, event changes no curve. - **R18-4 (root).** No `TransitionFallback` for a route edit. A route retargets only on its own curve's jump or on its own cell's record, so no carried live move is stretched or cut by a - rebuild, and B17-R14's window reads no route. + rebuild, and B17-R14's window reads no route. *Withdrawn for routes by R18-5 (U2a fold 1):* with + one shared ramp, a gate change of one source column restarted the whole ramp, so an open + column's move was re-timed (MAJOR-2) and per-lane events closer than `J` stretched the earlier + column (MINOR-1). Per-column ramps make the reason true: a gate change retargets its own column + only, so no other column's move is stretched or cut. - **Owner bytes (x86-64).** `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 96 -> 296 (cursors, spans, program `Arc`, arrival, rule); `GraphFollowRouteControlBinding` 64 -> 112 (`GraphFollowRouteSource` 48). Both are charged by `size_of`. `LIVE_ROUTE_OWNER_BYTES` stays 72. @@ -497,9 +504,13 @@ later tranche (after #1653). amendment's "every six-word route keeps its bytes"): the check lives in the carry section's move table, `carry_routes: Box<[RouteCarryStep]>` (move + `Option`, about 40 bytes on x86-64 against 8 before), allocated by `install_route_carry` on the control thread and charged - by `route_carry_retained_bytes`, the one place the charge is spelled. The executor and its layout + by `route_carry_retained_bytes`, the one place the charge is spelled. *Corrected by U2a fold 1 + (MINOR-3):* `RouteCarryStep` is back to 8 bytes; the checks live in a side table. The executor and its layout twin gain one `route_carry_pending: bool`. -- **No `TransitionFallback` (R18-4).** The carry never stretches or cuts a move. A carried ramp +- **No `TransitionFallback` (R18-4; reason corrected by R18-5, U2a fold 1).** With per-column + ramps the reason below holds column by column: a carried column whose target bits do not change + keeps its own ramp, length and position, whatever the other column does. The carry never + stretches or cuts a move. A carried ramp whose target bits do not change keeps its own ramp and length. A changed target is either the join's record (its own rule and length, as at any block entry) or the curve's own jump at the successor's node time over the session mute length, which an uninterrupted plan would make at a @@ -533,3 +544,86 @@ later tranche (after #1653). (b) red; carried comparison as a step -> six->seven (b) red; six-word comparison missing -> seven->six red. Six -> six carry keeps today's code path; `host-core` `route_mute` (9) and `live_routes` (13) pass. + +## Decision record: U2a fold 1 (batch 18) + +Fold of the U2a review (FAIL at `3d063f0ed`) under root ruling R18-5 (option (b), per-column +ramps). Files: `crates/graph/src/{lib.rs,runtime.rs}`, `crates/lane/src/kernels.rs` (one entry), +`crates/lane/tests/route_ramp.rs`; mechanical caller update in `crates/host-core/src/prepare.rs`. + +- **MAJOR-1 (activation offset).** `RouteActivity` gains `from: Box<[u32]>`, one offset per route, + allocated at bind only for a plan that binds a following route (empty otherwise). + `LiveFollowRoute::begin_block` returns `Option`: `None` inactive, `Some(o)` mixes from `o` + (`Some(0)` when active at entry). `execute_op` writes the bit and the offset each block. + `reduce_gated` / `reduce_gated_into` reduce the destination over frame ranges cut at the active + routes' offsets: a route counts on a range only from its offset on, so `[0, o)` leaves it out and + `[o, q)` sums it. With no offset inside the block the whole block is one range (same segments, + same kernels). The reduction kernels take a frame range (`get`/`get_mut`, no new trap). The + offsets are charged in `route_control_resources` through the following producers + (`activity_offset_bytes`, one `u32` per prepared route, once per plan). +- **MAJOR-2 / MINOR-1 (R18-5).** `LiveFollowRoute` holds `ColumnRamps`: one `IndexedRamp` and + position per source column (left: `ll`, `rl`; right: `lr`, `rr`). A retarget changes only a + column whose target bits change, from the coefficients it is at. A column's new ramp holds the + target in the other column's entries, so only its own entries decide ramp or step. New kernel + `lane::kernels::route_mix_column_ramps_block`: three parts (both columns ramp, one ramps, both + settled = `mix2x2_block` of the two targets), vector body at `L` (simd128 / NEON / AVX2) and one + outlined non-generic `f32` tail, as `route_mix_ramp_block`. With one ramp in both columns at one + position it is `route_mix_ramp_block` bit for bit. A settled following route therefore mixes the + static six-word plan's bits. Six-word routes are untouched (code, bytes, bits). +- **MAJOR-3 (carried record order).** `LiveFollowRoute::adopt` takes the predecessor's column + ramps, mute and heard curve (`[false; 2]` from a six-word predecessor). On the carried first + block, `FollowCursors::block` seeds the cursors at `τ_tap` but returns the placed curve. The cell + applies first, composed with the heard curve, over its own length; then the placed curve composes + and each changed column retargets over `J`. Seven -> six: the side-table check carries the + columns the heard curve zeroed. `finish_six_word_carry` applies a record composed with them + (`LiveRoute::apply_pending_heard`), then reopens to the record's own target over `J` on a bits + change; with no record, to the prepared target as before. The check is left whenever the bits + differ or the heard curve zeroes a column. +- **MINOR-2.** `adopt_predecessor` first runs the predecessor's pending comparisons + (`finish_route_carry` under `LiveSnapshot::ALL` and its held jump lengths) when that + predecessor was displaced before its first block. Not gated in graph (two swaps with a following + route need #1652c's attach). Proof comment at the call; the host-core carry tranche gates it. +- **MINOR-3.** `RouteCarryStep` is `{ successor, predecessor }` (8 B, as before #1652d). The checks + live in `GraphExecutor::route_checks: Option>`: one `RouteCarryCheck` + per move whose predecessor row is a following route (`GraphLiveRoute::following` -> + `GraphRouteMove::predecessor_follows`). `None` when no such move exists. + `can_carry_live_routes` refuses a seven -> six move without its slot. + `route_carry_retained_bytes(moves, following)` charges both. +- **NIT-1.** Recorded at `FollowCursors::block`: with no jump lengths or no node span the block has + no events, nothing is placed and the curve holds. A fresh route keeps its bind seed. A carried + route keeps the predecessor's heard curve, and with no lengths its compare is a step (`J = 0`). + Production plans always attach both. +- **Departures / limits.** (1) A six-word successor holds one ramp, so the seven -> six cases are + not per column. A record whose heard-curve composition differs from it reopens the whole route + over `J`, and the record's open columns take `J`, not `R`. Predecessor columns in different moves + become one ramp from where they are to the composite target over the longer remaining length + (`ColumnRamps::one_ramp`), which re-times the shorter move. (2) A route that opens inside a block + and is muted again by a seek's `Set` in the same block still sums `[set, q)` (no end offset). + (3) A column's ramp steps on a non-finite difference of its own entries only; the shared law + stepped all four. (4) `GraphRouteMove` and `GraphLiveRoute` gain one public field each. + host-core's join, its charge call and one unit test are updated mechanically + (`crates/host-core/src/prepare.rs`). +- **Measured (x86-64).** `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 296 -> 352 (`ColumnRamps` 112). `RouteCarryStep` + 40 -> 8. `RouteCarryCheck` 40 and `RouteCarryChecks` 24 (side table, automation sessions only). + `SixWordCarryCheck` 32. `RouteActivity` 64 -> 80: the `from` box header, in every plan with a + live route, charged by `size_of` in `route_activity_bound_bytes`. `LIVE_ROUTE_OWNER_BYTES` 72 + (unchanged). Realtime policy: 167 marked regions (166 at base; one new region for `ColumnRamps`). +- **Gates (all pass).** `crates/graph/src/runtime.rs` `follow_route_op_tests::carry::fold`: + `an_opening_route_is_left_out_of_its_destination_before_its_offset` (MAJOR-1, through + `execute_op`, negative feeds, `o` in {1, 63, 64, 127}, sole / first / second input); + `an_open_columns_move_keeps_its_length_across_a_gate_changing_carry` (MAJOR-2); + `per_lane_steps_thirty_samples_apart_each_end_with_the_mute_length` (MINOR-1); + `a_carried_record_composes_with_the_heard_curve_then_the_curve_jumps` (MAJOR-3, seven-word); + `a_six_word_successors_record_composes_with_the_heard_curve_then_reopens` (MAJOR-3, six-word). + `crates/lane/tests/route_ramp.rs`: + `equal_column_ramps_are_the_six_word_kernel_and_settled_ones_the_static_mix` and + `each_column_follows_its_own_ramp` (`each_lane!`: `f32`, `Simd4`, `Simd8` under avx2). Existing + #1652b/#1652d cases now expect per-column retargets (gate 3's reference keeps an unchanged + column's ramp; six -> seven (b) keeps the right column's move). +- **Red checks (temporary mutations, reverted).** No offset written -> MAJOR-1 red. M2 (drop + `set_active` for `LiveFollowRoute`) -> MAJOR-1 red. One shared ramp (any change restarts both + columns) -> MAJOR-2, MINOR-1, MAJOR-3 seven-word, gate 3 and six -> seven red. Curve placed + before the record -> MAJOR-3 seven-word red. Record governs alone in `finish_six_word_carry` + (the pre-fold code) -> MAJOR-3 six-word red. M1 analogue (drop the `return` after the record) + -> MAJOR-3 six-word red. Kernel: right column read from the left ramp -> `each_column...` red. + The one-ramping part assigning the ramping column's target -> `each_column...` red. diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 6a7175617..7892ef17d 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1445,6 +1445,10 @@ pub struct GraphFollowRouteControlProducer { activity_bytes: u64, /// The largest single allocation of that table, or `0` with it. activity_largest: u64, + /// The route-activity table's activation offsets (#1652 MAJOR-1): one `u32` per prepared + /// route, which bind allocates only for a plan with a following route, so the compile-time + /// estimate never charges it. The same for every producer of one attach. + activity_offset_bytes: u64, } impl GraphFollowRouteControlProducer { @@ -1483,7 +1487,8 @@ impl GraphFollowRouteControlProducer { /// One following route's binding and producer over one fresh seven-word cell (#1652 D1), reading /// `source` (#1652b). `activity_bytes` and `activity_largest` are the attach's route-activity charge, as for a -/// six-word producer. +/// six-word producer; `activity_routes` is the plan's prepared route count, whose activation +/// offsets bind allocates for a plan with a following route (#1652 MAJOR-1). #[cfg_attr(not(test), expect(dead_code, reason = "called by #1652c route attach"))] pub(crate) fn follow_route_control_pair( route_id: &StableGraphId, @@ -1491,6 +1496,7 @@ pub(crate) fn follow_route_control_pair( source: GraphFollowRouteSource, activity_bytes: u64, activity_largest: u64, + activity_routes: u64, ) -> ( GraphFollowRouteControlBinding, GraphFollowRouteControlProducer, @@ -1518,6 +1524,8 @@ pub(crate) fn follow_route_control_pair( writer: engine::realtime::CellWriter::new(), activity_bytes, activity_largest, + activity_offset_bytes: runtime::route_activity_offset_bytes(activity_routes) + .unwrap_or(u64::MAX), }, ) } @@ -1652,6 +1660,15 @@ pub fn route_control_resources( let (activity_bytes, activity_largest) = activity.fold((0, 0), |(bytes, largest), next| { (bytes.max(next.0), largest.max(next.1)) }); + // #1652 MAJOR-1: the activation offsets bind adds to the table for a plan with a following + // route; charged once, with the table. + let offsets = following + .iter() + .map(|producer| producer.activity_offset_bytes) + .max() + .unwrap_or(0); + let activity_bytes = activity_bytes.saturating_add(offsets); + let activity_largest = activity_largest.max(offsets); if six > 0 { largest = largest .max(lane) @@ -3913,12 +3930,13 @@ struct GraphExecutor { /// (and never allocated) when no strip delay line carries. carry_strip_delays: Box<[GraphStripDelayMove]>, /// The live-send section of the hand-over (#1284 D2), sorted by predecessor unit; empty (and - /// never allocated) when no live route carries. Each move holds the comparison a six-word - /// successor of a following predecessor leaves for its first block (#1652 D3). + /// never allocated) when no live route carries. carry_routes: Box<[runtime::RouteCarryStep]>, - /// Some move of `carry_routes` left a comparison for the first block after the swap (#1652 - /// D3); taken by that block. - route_carry_pending: bool, + /// The side table of the moves whose predecessor row is a following route (#1652 D3, + /// MINOR-3): the comparisons a six-word successor of a following predecessor leaves for its + /// first block. `None` (never allocated) when no such move exists, so a session without + /// stored mute automation keeps its bytes. + route_checks: Option>, /// The identity of every strip delay line and live route, read once by /// [`take_strip_delay_lines`] and [`take_live_routes`] right after bind and empty afterwards: /// a plan retains none of it. @@ -4871,6 +4889,9 @@ pub struct GraphLiveRoute { /// The route's runtime unit in this plan. Meaningful only to this plan's successor's carry /// program, which names it as the route to take. pub unit: u32, + /// Whether the route is a following route (#1652 D1): a successor's move from this row + /// carries [`GraphRouteMove::predecessor_follows`]. + pub following: bool, } impl GraphLiveRoute { @@ -4889,6 +4910,9 @@ pub struct GraphRouteMove { pub successor: u32, /// The route's unit in the plan the successor displaces. pub predecessor: u32, + /// Whether the predecessor's row is a following route ([`GraphLiveRoute::following`]): the + /// move then gets a slot in the section's side table (#1652 D3, MINOR-3). + pub predecessor_follows: bool, } /// The strip delay lines of a bound graph plan, in line order (#1284 D1); `None` for any other @@ -4974,6 +4998,18 @@ pub fn install_route_carry( } } moves.sort_unstable_by_key(|step| step.predecessor); + let slots: Box<[runtime::RouteCarryCheck]> = moves + .iter() + .zip(0_u32..) + .filter(|(step, _)| step.predecessor_follows) + .map(|(_, index)| runtime::RouteCarryCheck::new(index)) + .collect(); + executor.route_checks = (!slots.is_empty()).then(|| { + Box::new(RouteCarryChecks { + slots, + pending: false, + }) + }); executor.carry_routes = moves .into_iter() .map(runtime::RouteCarryStep::new) @@ -4981,6 +5017,14 @@ pub fn install_route_carry( Ok(()) } +/// The side table of a live-send section (#1652 D3, MINOR-3): one slot per move whose +/// predecessor row is a following route, in move order, and whether the swap block left a +/// comparison in one of them for the successor's first block. +pub(crate) struct RouteCarryChecks { + slots: Box<[runtime::RouteCarryCheck]>, + pending: bool, +} + /// The bytes a strip-delay section of `moves` lines retains in its plan (#1284): its move table. /// `None` on overflow. The one place the charge is spelled, so preparation's admission and /// [`carry_program_retained_bytes`] cannot disagree. @@ -4990,11 +5034,20 @@ pub fn strip_delay_carry_retained_bytes(moves: usize) -> Option { } /// The bytes a live-send section of `moves` routes retains in its plan (#1284, #1652 D3): its move -/// table, each move with its first-block comparison slot. `None` on overflow; spelled once, as -/// [`strip_delay_carry_retained_bytes`]. +/// table and, when `following` of its moves have a following route as their predecessor row, its +/// side table of first-block comparison slots (MINOR-3; nothing with `following == 0`). `None` on +/// overflow; spelled once, as [`strip_delay_carry_retained_bytes`]. #[must_use] -pub fn route_carry_retained_bytes(moves: usize) -> Option { - u64::try_from(moves.checked_mul(core::mem::size_of::())?).ok() +pub fn route_carry_retained_bytes(moves: usize, following: usize) -> Option { + let table = moves.checked_mul(core::mem::size_of::())?; + let checks = if following == 0 { + 0 + } else { + following + .checked_mul(core::mem::size_of::())? + .checked_add(core::mem::size_of::())? + }; + u64::try_from(table.checked_add(checks)?).ok() } /// The session timeline's sample at `source_read_sample` in a graph plan's source set (#1641 D8): @@ -5051,8 +5104,14 @@ pub fn carry_program_retained_bytes(plan: &mut PreparedRenderPlan) -> u64 { delay_carry_retained_bytes(executor.carry_delays.len()).unwrap_or(u64::MAX); let strip_delays = strip_delay_carry_retained_bytes(executor.carry_strip_delays.len()) .unwrap_or(u64::MAX); - let routes = - route_carry_retained_bytes(executor.carry_routes.len()).unwrap_or(u64::MAX); + let routes = route_carry_retained_bytes( + executor.carry_routes.len(), + executor + .route_checks + .as_deref() + .map_or(0, |checks| checks.slots.len()), + ) + .unwrap_or(u64::MAX); sources .saturating_add(delays) .saturating_add(strip_delays) @@ -5230,7 +5289,7 @@ struct GraphExecutorWithoutSplitPairTable { compensation_lines: Vec, carry_strip_delays: Box<[GraphStripDelayMove]>, carry_routes: Box<[runtime::RouteCarryStep]>, - route_carry_pending: bool, + route_checks: Option>, bind_identities: runtime::BindIdentities, live_snapshot: Option, jump_lengths: Option>, @@ -5357,7 +5416,7 @@ impl GraphExecutor { compensation_lines, carry_strip_delays: Box::default(), carry_routes: Box::default(), - route_carry_pending: false, + route_checks: None, bind_identities, live_snapshot: None, jump_lengths: None, @@ -5406,6 +5465,26 @@ impl PreparedPlanExecutor for GraphExecutor { if predecessor.identity != program.predecessor { return CarryOutcome::PredecessorMismatch; } + // #1652 MINOR-2: a predecessor displaced before its own first block still holds the + // comparisons its carry left. Take them first, as that block would have (under every + // snapshot and its held jump lengths, as the carry applies pending values), so this + // plan carries the state the predecessor's next block would have rendered from. (Not + // gated in graph: two swaps with a following route need host-core's attach, #1652c; the + // host-core carry tranche gates it.) + if let Some(checks) = predecessor.route_checks.as_deref_mut() + && core::mem::take(&mut checks.pending) + { + let lengths = predecessor + .jump_lengths + .as_deref() + .map(|reader| reader.held); + predecessor.runtime.finish_route_carry( + &predecessor.carry_routes, + &mut checks.slots, + LiveSnapshot::ALL, + lengths, + ); + } // Issue #1276: every builtin-input move must resolve before anything moves, so a refused // hand-over moves nothing at all. if !self @@ -5448,9 +5527,13 @@ impl PreparedPlanExecutor for GraphExecutor { if !self .runtime .can_carry_strip_delays(&predecessor.runtime, &self.carry_strip_delays) - || !self - .runtime - .can_carry_live_routes(&predecessor.runtime, &self.carry_routes) + || !self.runtime.can_carry_live_routes( + &predecessor.runtime, + &self.carry_routes, + self.route_checks + .as_deref() + .map_or(&[][..], |checks| &checks.slots[..]), + ) { return CarryOutcome::PredecessorMismatch; } @@ -5475,9 +5558,16 @@ impl PreparedPlanExecutor for GraphExecutor { .carry_delay_lines(&mut predecessor.runtime, &self.carry_delays); self.runtime .carry_strip_delays(&mut predecessor.runtime, &self.carry_strip_delays); - self.route_carry_pending = self - .runtime - .carry_live_routes(&mut predecessor.runtime, &mut self.carry_routes); + let mut no_checks = []; + let checks = self.route_checks.as_deref_mut(); + let pending = self.runtime.carry_live_routes( + &mut predecessor.runtime, + &self.carry_routes, + checks.map_or(&mut no_checks[..], |checks| &mut checks.slots[..]), + ); + if let Some(checks) = self.route_checks.as_deref_mut() { + checks.pending = pending; + } let refused = self.runtime.carry_effect_lanes( &mut predecessor.runtime, &self.carry_effects, @@ -5602,7 +5692,7 @@ impl PreparedPlanExecutor for GraphExecutor { compensation_lines: _, carry_strip_delays: _, carry_routes, - route_carry_pending, + route_checks, bind_identities: _, live_snapshot, jump_lengths, @@ -5678,8 +5768,15 @@ impl PreparedPlanExecutor for GraphExecutor { ); // #1652 D3: a six-word successor of a following predecessor compares at its first block, // under this block's snapshot and jump lengths, before its op runs. - if core::mem::take(route_carry_pending) { - runtime.finish_route_carry(carry_routes, live, block_time.jump_lengths()); + if let Some(checks) = route_checks.as_deref_mut() + && core::mem::take(&mut checks.pending) + { + runtime.finish_route_carry( + carry_routes, + &mut checks.slots, + live, + block_time.jump_lengths(), + ); } // 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. @@ -10526,7 +10623,7 @@ mod follow_route_tests { fn the_producer_write_reaches_the_binding_under_the_published_revision() { let superseded = LiveValuesSuperseded::new(); let (mut binding, mut producer) = - follow_route_control_pair(&route_id("send"), &superseded, one_lane_source(), 0, 0); + follow_route_control_pair(&route_id("send"), &superseded, one_lane_source(), 0, 0, 0); assert_eq!(&*producer.route_id, "send"); assert_eq!( binding.node, @@ -10584,6 +10681,10 @@ mod follow_route_tests { fn the_charge_counts_each_kind_its_own_bytes_and_the_activity_table_once() { let size = |bytes: usize| bytes as u64; let superseded = LiveValuesSuperseded::new(); + /// The plan's prepared routes: their activation offsets join the activity charge + /// once, for a plan with a following route (#1652 MAJOR-1). + const ACTIVITY_ROUTES: u64 = 7; + let offsets = ACTIVITY_ROUTES * size(core::mem::size_of::()); let activity = (4_096, 1_024); let six = [ six_word_producer("a", activity), @@ -10598,6 +10699,7 @@ mod follow_route_tests { one_lane_source(), activity.0, activity.1, + ACTIVITY_ROUTES, ) .1 }) @@ -10629,7 +10731,7 @@ mod follow_route_tests { assert_eq!(both.owner_bytes, 2 * six_owner + 3 * seven_owner); assert_eq!(both.producer_table_bytes, six_table + seven_table); assert_eq!(both.route_id_bytes, ids); - assert_eq!(both.activity_bytes, activity.0); + assert_eq!(both.activity_bytes, activity.0 + offsets); assert_eq!( both.total_bytes, 2 * six_cell @@ -10640,6 +10742,7 @@ mod follow_route_tests { + seven_table + ids + activity.0 + + offsets ); assert!(both.largest_allocation_bytes >= seven_cell.max(activity.1)); diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index e931becd8..a7eccb5fb 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -316,8 +316,8 @@ use effect_contract::{ }; use lane::Lane; use lane::kernels::{ - IndexedRamp, mix2x2_block, ordered_accumulate_block, pdc_delay_block, route_mix_ramp_block, - sum_into_block, + IndexedRamp, mix2x2_block, ordered_accumulate_block, pdc_delay_block, + route_mix_column_ramps_block, route_mix_ramp_block, sum_into_block, }; use rack::{BankChain, BankMembers, BankPlaneViews, FoldCohort, ResidentFoldCohort}; @@ -438,6 +438,38 @@ fn reduce_plane(arena: &mut DisjointArena, plane: usize, out: u32, inputs: &[u32 } } +/// [`reduce_plane`] over the frames `range` of the block only (#1652 MAJOR-1, a route +/// destination split at a following route's activation offset): the same three arms, over the +/// same inputs in the same order, with the same kernels, so every frame is the one left-to-right +/// chain. A range outside the block reduces nothing. +#[inline] +fn reduce_plane_range( + arena: &mut DisjointArena, + plane: usize, + out: u32, + inputs: &[u32], + range: core::ops::Range, +) { + match inputs { + [] => { + if let Some(frames) = arena.write(plane, out).get_mut(range) { + frames.fill(0.0); + } + } + [single] => { + if *single != out { + let (output, input) = arena.write_read(plane, out, *single); + if let (Some(output), Some(input)) = + (output.get_mut(range.clone()), input.get(range)) + { + output.copy_from_slice(input); + } + } + } + [_, _, ..] => reduce_run::(arena, plane, out, inputs, true, range), + } +} + /// Reads one arena borrow forms at once: [`DisjointArena::write_read_many`] takes one to eight. const REDUCE_GROUP: usize = 8; @@ -457,13 +489,14 @@ fn reduce_many(arena: &mut DisjointArena, plane: usize, out: u32, input inputs.len() >= 2, "fan-in zero is a fill and fan-in one a copy" ); - reduce_run::(arena, plane, out, inputs, true); + reduce_run::(arena, plane, out, inputs, true, 0..arena.frames()); } /// [`reduce_many`]'s body over one run of one or more inputs: with `store`, the run's first group /// stores and each later group reloads and adds; without it, every group reloads the running sum /// already in `out` and adds (issue #1217 D3, a later run of active inputs). The order is the -/// one left-to-right chain either way. +/// one left-to-right chain either way. Only the frames `range` are reduced: the whole block, or +/// one range of a destination split at a following route's activation offset (#1652 MAJOR-1). #[inline(always)] fn reduce_run( arena: &mut DisjointArena, @@ -471,18 +504,20 @@ fn reduce_run( out: u32, inputs: &[u32], store: bool, + range: core::ops::Range, ) { for (index, group) in inputs.chunks(REDUCE_GROUP).enumerate() { let initial_store = store && index == 0; + let at = range.clone(); let reduced = match group.len() { - 1 => reduce_group::(arena, plane, out, group, initial_store), - 2 => reduce_group::(arena, plane, out, group, initial_store), - 3 => reduce_group::(arena, plane, out, group, initial_store), - 4 => reduce_group::(arena, plane, out, group, initial_store), - 5 => reduce_group::(arena, plane, out, group, initial_store), - 6 => reduce_group::(arena, plane, out, group, initial_store), - 7 => reduce_group::(arena, plane, out, group, initial_store), - 8 => reduce_group::(arena, plane, out, group, initial_store), + 1 => reduce_group::(arena, plane, out, group, initial_store, at), + 2 => reduce_group::(arena, plane, out, group, initial_store, at), + 3 => reduce_group::(arena, plane, out, group, initial_store, at), + 4 => reduce_group::(arena, plane, out, group, initial_store, at), + 5 => reduce_group::(arena, plane, out, group, initial_store, at), + 6 => reduce_group::(arena, plane, out, group, initial_store, at), + 7 => reduce_group::(arena, plane, out, group, initial_store, at), + 8 => reduce_group::(arena, plane, out, group, initial_store, at), _ => false, }; // Unreachable for a lowered program: a multi-input op's output is a fresh slot, never one @@ -507,6 +542,7 @@ fn reduce_group( out: u32, group: &[u32], initial_store: bool, + range: core::ops::Range, ) -> bool { let Ok(ids) = <&[u32; N]>::try_from(group) else { return false; @@ -514,9 +550,28 @@ fn reduce_group( let Some((output, sources)) = arena.write_read_many(plane, out, ids) else { return false; }; + let Some(output) = output.get_mut(range.clone()) else { + return false; + }; + let Some(sources) = window(sources, range) else { + return false; + }; accumulate_group::(output, sources, initial_store) } +/// The frames `range` of every source of a group, or `None` when one is shorter. +#[inline(always)] +fn window( + sources: [&[f32]; N], + range: core::ops::Range, +) -> Option<[&[f32]; N]> { + let mut windows = [&[][..]; N]; + for (window, source) in windows.iter_mut().zip(sources) { + *window = source.get(range.clone())?; + } + Some(windows) +} + /// `output = s0 + s1 + ...` when `initial_store`, otherwise `output = ((output + s0) + s1) + ...`, /// per frame and left to right, in one pass over `output`. /// @@ -625,6 +680,36 @@ fn reduce_plane_into( } } +/// [`reduce_plane_into`] over the frames `range` only (#1652 MAJOR-1), as [`reduce_plane_range`]. +#[inline] +fn reduce_plane_into_range( + arena: &DisjointArena, + plane: usize, + out: u32, + target: &mut [f32], + inputs: &[u32], + range: core::ops::Range, +) { + match inputs { + [] => { + if let Some(frames) = target.get_mut(range) { + frames.fill(0.0); + } + } + [single] => { + if *single != out + && let (Some(target), Some(input)) = ( + target.get_mut(range.clone()), + arena.read(plane, *single).get(range), + ) + { + target.copy_from_slice(input); + } + } + [_, _, ..] => reduce_run_into::(arena, plane, target, inputs, true, range), + } +} + /// [`reduce_many`] into the host's plane: the same [`REDUCE_GROUP`] chunking, the same /// `initial_store` for the first group only, and the same refusal rule. #[inline(always)] @@ -638,7 +723,8 @@ fn reduce_many_into( inputs.len() >= 2, "fan-in zero is a fill and fan-in one a copy" ); - reduce_run_into::(arena, plane, target, inputs, true); + let frames = target.len(); + reduce_run_into::(arena, plane, target, inputs, true, 0..frames); } /// [`reduce_run`] into the host's plane. @@ -649,18 +735,20 @@ fn reduce_run_into( target: &mut [f32], inputs: &[u32], store: bool, + range: core::ops::Range, ) { for (index, group) in inputs.chunks(REDUCE_GROUP).enumerate() { let initial_store = store && index == 0; + let at = range.clone(); let reduced = match group.len() { - 1 => reduce_group_into::(arena, plane, target, group, initial_store), - 2 => reduce_group_into::(arena, plane, target, group, initial_store), - 3 => reduce_group_into::(arena, plane, target, group, initial_store), - 4 => reduce_group_into::(arena, plane, target, group, initial_store), - 5 => reduce_group_into::(arena, plane, target, group, initial_store), - 6 => reduce_group_into::(arena, plane, target, group, initial_store), - 7 => reduce_group_into::(arena, plane, target, group, initial_store), - 8 => reduce_group_into::(arena, plane, target, group, initial_store), + 1 => reduce_group_into::(arena, plane, target, group, initial_store, at), + 2 => reduce_group_into::(arena, plane, target, group, initial_store, at), + 3 => reduce_group_into::(arena, plane, target, group, initial_store, at), + 4 => reduce_group_into::(arena, plane, target, group, initial_store, at), + 5 => reduce_group_into::(arena, plane, target, group, initial_store, at), + 6 => reduce_group_into::(arena, plane, target, group, initial_store, at), + 7 => reduce_group_into::(arena, plane, target, group, initial_store, at), + 8 => reduce_group_into::(arena, plane, target, group, initial_store, at), _ => false, }; // As in `reduce_many`: a refused group ends the reduction rather than let a later group @@ -685,11 +773,17 @@ fn reduce_group_into( target: &mut [f32], group: &[u32], initial_store: bool, + range: core::ops::Range, ) -> bool { let Ok(ids) = <&[u32; N]>::try_from(group) else { return false; }; - let sources = (*ids).map(|input| arena.read(plane, input)); + let Some(target) = target.get_mut(range.clone()) else { + return false; + }; + let Some(sources) = window((*ids).map(|input| arena.read(plane, input)), range) else { + return false; + }; accumulate_group::(target, sources, initial_store) } @@ -713,17 +807,19 @@ enum SumSegment { /// pair's activity bit is read once. With every input active the walk hands on one /// `Store(0, count)`, which is exactly today's reduction. An inactive in-place sole input hands on /// `Zero` alone, so the destination fills `+0.0` over the raw tap its buffer still holds. +/// +/// `active(route)` is route `route`'s bit over the frames being reduced. #[inline] fn route_segments( count: usize, pairs: &[RouteInput], - active: &[bool], + active: impl Fn(usize) -> bool, mut sink: impl FnMut(SumSegment), ) { let mut start = 0; let mut stored = false; for pair in pairs { - if active.get(pair.route as usize).copied().unwrap_or(true) { + if active(pair.route as usize) { continue; } let position = pair.position as usize; @@ -749,28 +845,99 @@ fn route_segments( } } +/// A route destination's activity this block (issue #1217 D5, #1652 MAJOR-1): each route's bit, +/// and, for a following route that opens inside the block, the offset it mixes from. +#[derive(Clone, Copy)] +struct GatedInputs<'a> { + pairs: &'a [RouteInput], + active: &'a [bool], + /// [`RouteActivity::from`]: empty for a plan with no following route. + from: &'a [u32], +} + +impl GatedInputs<'_> { + /// Route `route`'s activation offset: `0` (the whole block) unless a following route opened + /// inside the block. + #[inline] + fn from(&self, route: usize) -> usize { + self.from.get(route).copied().unwrap_or(0) as usize + } + + /// Whether route `route` mixes the frames from `start` on. + #[inline] + fn active_at(&self, route: usize, start: usize) -> bool { + self.active.get(route).copied().unwrap_or(true) && self.from(route) <= start + } + + /// The first activation offset in `(start, frames)` of an active route, or `frames`: where the + /// destination's next frame range begins. Bounded by the destination's route inputs. + #[inline] + fn next_boundary(&self, start: usize, frames: usize) -> usize { + self.pairs + .iter() + .map(|pair| pair.route as usize) + .filter(|&route| self.active.get(route).copied().unwrap_or(true)) + .map(|route| self.from(route)) + .filter(|&from| from > start && from < frames) + .min() + .unwrap_or(frames) + } +} + /// [`reduce_plane`] over both planes of a route destination (issue #1217 D3). +/// +/// #1652 MAJOR-1: a following route that was inactive at block entry and opens at offset `o` is +/// left out of the frames `[0, o)` and summed over `[o, q)`, so the destination is the block split +/// at `o` bit for bit (the route's `[0, o)` mix of its silent coefficients, a signed zero or a NaN +/// from a non-finite input, is never read). With no such route the block is one range, today's +/// reduction unchanged. #[inline] -fn reduce_gated( - arena: &mut DisjointArena, - out: u32, - inputs: &[u32], - pairs: &[RouteInput], - active: &[bool], -) { - route_segments(inputs.len(), pairs, active, |segment| { - for plane in 0..2 { - match segment { - SumSegment::Zero => arena.write(plane, out).fill(0.0), - SumSegment::Store(start, end) => { - reduce_plane(arena, plane, out, &inputs[start..end]); - } - SumSegment::Add(start, end) => { - reduce_run::(arena, plane, out, &inputs[start..end], false); +fn reduce_gated(arena: &mut DisjointArena, out: u32, inputs: &[u32], gated: GatedInputs<'_>) { + let frames = arena.frames(); + let mut start = 0; + loop { + let end = gated.next_boundary(start, frames); + let range = start..end; + route_segments( + inputs.len(), + gated.pairs, + |route| gated.active_at(route, start), + |segment| { + for plane in 0..2 { + match segment { + SumSegment::Zero => { + if let Some(frames) = arena.write(plane, out).get_mut(range.clone()) { + frames.fill(0.0); + } + } + SumSegment::Store(first, last) => { + reduce_plane_range( + arena, + plane, + out, + &inputs[first..last], + range.clone(), + ); + } + SumSegment::Add(first, last) => { + reduce_run::( + arena, + plane, + out, + &inputs[first..last], + false, + range.clone(), + ); + } + } } - } + }, + ); + if end >= frames { + return; } - }); + start = end; + } } /// [`reduce_gated`] into the host's planes: the session Output as a route destination. @@ -780,22 +947,54 @@ fn reduce_gated_into( out: u32, (left, right): (&mut [f32], &mut [f32]), inputs: &[u32], - pairs: &[RouteInput], - active: &[bool], + gated: GatedInputs<'_>, ) { - route_segments(inputs.len(), pairs, active, |segment| { - for (plane, target) in [(0, &mut *left), (1, &mut *right)] { - match segment { - SumSegment::Zero => target.fill(0.0), - SumSegment::Store(start, end) => { - reduce_plane_into(arena, plane, out, target, &inputs[start..end]); - } - SumSegment::Add(start, end) => { - reduce_run_into::(arena, plane, target, &inputs[start..end], false); + let frames = arena.frames(); + let mut start = 0; + loop { + let end = gated.next_boundary(start, frames); + let range = start..end; + route_segments( + inputs.len(), + gated.pairs, + |route| gated.active_at(route, start), + |segment| { + for (plane, target) in [(0, &mut *left), (1, &mut *right)] { + match segment { + SumSegment::Zero => { + if let Some(frames) = target.get_mut(range.clone()) { + frames.fill(0.0); + } + } + SumSegment::Store(first, last) => { + reduce_plane_into_range( + arena, + plane, + out, + target, + &inputs[first..last], + range.clone(), + ); + } + SumSegment::Add(first, last) => { + reduce_run_into::( + arena, + plane, + target, + &inputs[first..last], + false, + range.clone(), + ); + } + } } - } + }, + ); + if end >= frames { + return; } - }); + start = end; + } } /// One route input of a destination op (issue #1217 D5): its position in the op's inputs, and @@ -832,6 +1031,12 @@ pub(crate) struct RouteActivity { destinations: Box<[(u32, u32)]>, /// Every destination's route inputs, destination by destination, by ascending position. inputs: Box<[RouteInput]>, + /// #1652 MAJOR-1: per route, in canonical route-ID order, the offset from which the route + /// mixes this block (`0`: the whole block), written once per block by a following route's op + /// before its destination reads it. Allocated at bind only for a plan that binds a following + /// route (empty otherwise, so a plan without stored mute automation keeps its bytes); a + /// six-word or prepared route's entry stays `0`. + from: Box<[u32]>, } impl RouteActivity { @@ -875,6 +1080,25 @@ impl RouteActivity { *bit = active; } } + + /// #1652 MAJOR-1: a following route's op writes its activation offset, once per block, with + /// its bit and before its destination reads it. + #[inline] + fn set_from(&mut self, route: usize, from: u32) { + if let Some(slot) = self.from.get_mut(route) { + *slot = from; + } + } + + /// Unit `unit`'s route inputs and the routes' activity, when its op is a route destination. + #[inline] + fn gated(&self, unit: usize) -> Option> { + Some(GatedInputs { + pairs: self.destination_of(unit)?, + active: &self.active, + from: &self.from, + }) + } } /// One live route's render state (issue #1220 D3-D6): the indexed ramp it mixes by, how far into @@ -926,37 +1150,78 @@ impl LiveRoute { /// in every block, active route or not (#1504 D3). It allocates nothing and takes no lock. #[inline] fn apply_pending(&mut self, snapshot: LiveSnapshot) -> usize { - let control = &mut *self.control; let mut applied = 0; - if control.dirty.pending(&control.lane.dirty) & 1 != 0 - && let CellRead::Applied { words, .. } = control.reader.read_counted( - &control.lane.cell, - snapshot, - &mut control.dirty, - 0, - &control.superseded, - ) - { - // Only `write` fills the cell, from a validated record, so the words decode; words no - // record wrote retarget nothing. - let record = crate::RouteControlRecord::from_words(words); - debug_assert!(record.is_some(), "a route cell held words no record wrote"); - if let Some(record) = record { - self.ramp = IndexedRamp::new( - self.ramp.coefficients_at(self.position), - record.target(), - record.length(), - ); - self.mute = record.mute(); - self.position = 0; - applied = 1; - } + if let Some(record) = self.read_record(snapshot) { + self.ramp = IndexedRamp::new( + self.ramp.coefficients_at(self.position), + record.target(), + record.length(), + ); + self.mute = record.mute(); + self.position = 0; + applied = 1; } #[cfg(any(test, feature = "test-support"))] test_only_count_route_drained(self.route as usize, applied, snapshot); applied } + /// The cell's newest value not applied before, under `snapshot` (#1347 D3, #1312 D1, D2), the + /// one read [`Self::apply_pending`] and [`Self::apply_pending_heard`] share. + #[inline] + fn read_record(&mut self, snapshot: LiveSnapshot) -> Option { + let control = &mut *self.control; + if control.dirty.pending(&control.lane.dirty) & 1 == 0 { + return None; + } + let CellRead::Applied { words, .. } = control.reader.read_counted( + &control.lane.cell, + snapshot, + &mut control.dirty, + 0, + &control.superseded, + ) else { + return None; + }; + // Only `write` fills the cell, from a validated record, so the words decode; words no + // record wrote retarget nothing. + let record = crate::RouteControlRecord::from_words(words); + debug_assert!(record.is_some(), "a route cell held words no record wrote"); + record + } + + /// #1652 D3, MAJOR-3: [`Self::apply_pending`] at a six-word successor's first block after a + /// following predecessor, with the columns its heard curve zeroed (`zeroed`) zeroed in the + /// record's target, as the uninterrupted plan's block entry composes a record with the curve + /// before the curve's events. Returns the record's own target and mute, which the curve's + /// removal then reopens to ([`finish_six_word_carry`]); `None` with no new value. + fn apply_pending_heard( + &mut self, + snapshot: LiveSnapshot, + zeroed: [bool; 2], + ) -> Option<([f32; 4], bool)> { + let record = self.read_record(snapshot); + #[cfg(any(test, feature = "test-support"))] + test_only_count_route_drained(self.route as usize, usize::from(record.is_some()), snapshot); + let record = record?; + let mut target = record.target(); + for (indices, zeroed) in COLUMNS.iter().zip(zeroed) { + if zeroed { + for &index in indices { + target[index] = 0.0; + } + } + } + self.ramp = IndexedRamp::new( + self.ramp.coefficients_at(self.position), + target, + record.length(), + ); + self.mute = record.mute() || zeroed == [true; 2]; + self.position = 0; + Some((record.target(), record.mute())) + } + /// D4: whether the route mixes this block, decided after the drain and before anything /// reads it. Inactive only when muted, past its ramp, and undelayed: the block in which a mute /// ramp ends is mixed whole, and a delayed route keeps feeding its line. @@ -1012,18 +1277,16 @@ impl LiveRoute { /// an automated mute lane. Its own type, boxed beside [`LiveRoute`], so a six-word route keeps its /// bytes ([`live_route_owner_bytes`]), its code path and its bits. /// -/// The ramp fields mean what [`LiveRoute`]'s do. `transform` and `flags` are the last applied -/// cell words (or the seeded ones): render gates the open transform itself -/// ([`crate::RouteFollowFlags::gate`], [`gated_route_coefficients`]) at the block entry after its -/// cell changes and at every event of the source lanes' stored mute curves, which it evaluates -/// with its own cursors ([`FollowCursors`]) in the node time of its tap, -/// `τ_tap(r) = timeline(r + ΣP - a(tap))`. Only a target whose bits change retargets: a new -/// [`IndexedRamp`] from the coefficients the route is at. +/// `transform` and `flags` are the last applied cell words (or the seeded ones): render gates the +/// open transform itself ([`crate::RouteFollowFlags::gate`], [`gated_route_coefficients`]) at the +/// block entry after its cell changes and at every event of the source lanes' stored mute curves, +/// which it evaluates with its own cursors ([`FollowCursors`]) in the node time of its tap, +/// `τ_tap(r) = timeline(r + ΣP - a(tap))`. Only a source column whose target bits change +/// retargets: a new [`IndexedRamp`] for that column from the coefficients it is at +/// ([`ColumnRamps`], root ruling R18-5). pub(crate) struct LiveFollowRoute { - /// The current ramp; `ramp.coefficients_at(position)` are the last mixed frame's coefficients. - ramp: IndexedRamp, - /// The ramp index of the last frame mixed, saturated at `ramp.length`; never wraps. - position: u32, + /// The two column ramps and how far into each the route is. + ramps: ColumnRamps, /// Whether the current target silences the route. mute: bool, /// Whether the route's one consumer input carries a compensation delay. @@ -1043,6 +1306,136 @@ pub(crate) struct LiveFollowRoute { block: FollowBlock, } +/// A following route's ramps, one [`IndexedRamp`] per source column (#1652, root ruling R18-5): +/// column 0 carries the source's left input (`ll`, `rl`, coefficient indices 0 and 2), column 1 +/// its right input (`lr`, `rr`, indices 1 and 3). A gate change of one source lane retargets its +/// column alone, so an open column's move in flight keeps its own start, length and position, +/// across an in-plan curve event and across a plan swap. A ramp's entries of the other column are +/// never read. Mixed by [`route_mix_column_ramps_block`], which is [`route_mix_ramp_block`] bit +/// for bit when both columns hold one ramp at one position, so a settled following route mixes +/// exactly what a prepared route of its target mixes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ColumnRamps { + ramps: [IndexedRamp; 2], + /// Per column, the ramp index of the last frame mixed, saturated at its ramp's length. + positions: [u32; 2], +} + +/// The coefficient indices of each source column: left `[ll, rl]`, right `[lr, rr]`. +const COLUMNS: [[usize; 2]; 2] = [[0, 2], [1, 3]]; + +/// Column 0's entries of `zero` and column 1's of `one`. +const fn by_column(zero: [f32; 4], one: [f32; 4]) -> [f32; 4] { + [zero[0], one[1], zero[2], one[3]] +} + +impl ColumnRamps { + /// Both columns settled on `target`. + const fn settled(target: [f32; 4]) -> Self { + Self { + ramps: [IndexedRamp::settled(target); 2], + positions: [0; 2], + } + } + + /// Both columns on one ramp at one position: a six-word route's ramp state. + const fn shared(ramp: IndexedRamp, position: u32) -> Self { + Self { + ramps: [ramp; 2], + positions: [position; 2], + } + } +} + +// REALTIME_POLICY_BEGIN +impl ColumnRamps { + /// The coefficients from each column's ramp end on. + fn target(&self) -> [f32; 4] { + by_column(self.ramps[0].target, self.ramps[1].target) + } + + /// The last mixed frame's coefficients, column by column. + fn coefficients(&self) -> [f32; 4] { + by_column( + self.ramps[0].coefficients_at(self.positions[0]), + self.ramps[1].coefficients_at(self.positions[1]), + ) + } + + /// Whether both columns are past their ramps. + const fn is_settled(&self) -> bool { + self.positions[0] >= self.ramps[0].length && self.positions[1] >= self.ramps[1].length + } + + /// Retarget each column whose target bits change, from the coefficients it is at, over + /// `length`; a column whose bits do not change keeps its ramp and position. A column's new + /// ramp holds the target in the other column's entries, so only its own entries decide + /// whether it ramps or steps ([`IndexedRamp::new`]'s non-finite rule). + fn retarget(&mut self, target: [f32; 4], length: u32) { + let at = self.coefficients(); + for (column, indices) in COLUMNS.iter().enumerate() { + let (Some(ramp), Some(position)) = + (self.ramps.get_mut(column), self.positions.get_mut(column)) + else { + continue; + }; + if indices + .iter() + .all(|&index| target[index].to_bits() == ramp.target[index].to_bits()) + { + continue; + } + let mut start = target; + for &index in indices { + start[index] = at[index]; + } + *ramp = IndexedRamp::new(start, target, length); + *position = 0; + } + } + + /// Mix `left.len()` frames by both columns, then advance and saturate each position. + #[inline] + fn mix(&mut self, left: &mut [f32], right: &mut [f32]) { + route_mix_column_ramps_block::(left, right, &self.ramps, self.positions); + let frames = u32::try_from(left.len()).unwrap_or(u32::MAX); + for (position, ramp) in self.positions.iter_mut().zip(&self.ramps) { + *position = core::cmp::min(position.saturating_add(frames), ramp.length); + } + } + + /// The one ramp a six-word successor holds (#1652 D3, the curve removed). Exact when both + /// columns have settled, or hold ramps of one length at one position (the composite of their + /// own entries is then the ramp both columns follow). Columns in different moves have no + /// one-ramp form: the successor ramps from the coefficients they are at to their targets + /// over the longer remaining length, which re-times the shorter move (a six-word route has + /// one ramp; recorded in #1652's U2a fold record). + fn one_ramp(&self) -> (IndexedRamp, u32) { + let [zero, one] = &self.ramps; + if self.is_settled() { + return (IndexedRamp::settled(self.target()), 0); + } + if zero.length == one.length && self.positions[0] == self.positions[1] { + let ramp = IndexedRamp { + start: by_column(zero.start, one.start), + step: by_column(zero.step, one.step), + target: self.target(), + length: zero.length, + }; + return (ramp, self.positions[0]); + } + let remaining = core::cmp::max( + zero.length.saturating_sub(self.positions[0]), + one.length.saturating_sub(self.positions[1]), + ); + ( + IndexedRamp::new(self.coefficients(), self.target(), remaining), + 0, + ) + } +} +// REALTIME_POLICY_END + /// A following route's own cursors over its source strip's stored mute cells (#1652 D2): the /// plan's immutable program, a cell and a [`automation::CellState`] per source lane, the curve /// state the route has heard, `a(tap)` and the mute row's jump-length rule. @@ -1051,7 +1444,8 @@ struct FollowCursors { /// `[L, R]`; `None` for a lane the plan does not automate. cells: [Option; 2], states: [automation::CellState; 2], - /// Per lane, the curve value the route has heard (`value != 0`), from the seed on. + /// Per lane, the curve value the route has heard (`value != 0`), from the seed on; on a + /// carried route, the predecessor's until its first block places the cursors. curve: [bool; 2], /// `a(tap)`. arrival: u64, @@ -1067,9 +1461,9 @@ enum FollowStart { /// its first sample when the curve there differs from the seed (#1647 D2, as the strip's /// fresh lane). Fresh, - /// The route took a predecessor's ramp at a plan swap (D3): its first block places its - /// cursors at its own node time with the curve's value there (no `Set`), and then compares its - /// composed target with the adopted ramp's. + /// The route took a predecessor's ramps and heard curve at a plan swap (D3): its first block + /// applies its cell with the heard curve, then places its cursors at its own node time with + /// the curve's value there (no `Set`) and compares. Carried, /// Every later block. Running, @@ -1095,8 +1489,8 @@ impl LiveFollowRoute { /// The bound route (#1652b): its open transform `transform` (the plan's prepared route), its /// cell's reader `control` and its source `source`. Each automated lane's cursor is seeded with /// the curve's first `start_value`, and the bound target composes that curve with the seeded - /// flags, as #1651's prepared stage mute does; the ramp is settled on it, so an idle route mixes - /// exactly what a prepared route of that gate mixes. Control side. + /// flags, as #1651's prepared stage mute does; both column ramps are settled on it, so an idle + /// route mixes exactly what a prepared route of that gate mixes. Control side. pub(crate) fn new( transform: crate::RouteTransform, control: Box, @@ -1125,8 +1519,7 @@ impl LiveFollowRoute { }; let (target, mute) = follow.compose(&transform, source.flags, curve); Self { - ramp: IndexedRamp::settled(target), - position: 0, + ramps: ColumnRamps::settled(target), mute, delayed: false, route: u32::MAX, @@ -1156,19 +1549,32 @@ impl FollowCursors { follow_target(self.automated(), transform, flags, curve) } + /// Per column, whether the curve the route has heard zeroes it under `flags`: the curve's + /// share of [`crate::RouteFollowFlags::gate`] (`follows_mute && automated && curve`). + fn heard_zeroed(&self, flags: crate::RouteFollowFlags) -> [bool; 2] { + let automated = self.automated(); + [0, 1].map(|lane| flags.follows_mute && automated[lane] && self.curve[lane]) + } + /// The block's spans at the tap's node time (`node_span(a(tap))`, R17-3) and its mute jump /// length (the host-core rule over the block's lengths, whenever the block has them). Takes - /// the start: the first block of a fresh route with no seek reaching it lands a `Set` at - /// offset 0 on a lane whose curve there differs from what the lane holds; the first block of a - /// carried route instead seeds each lane with the curve's value at its first sample (D3: the - /// successor places its cursors fresh, and its block entry compares). Returns whether the - /// block is a carried route's first. - fn block(&mut self, time: &crate::GraphBlockTime<'_>) -> (FollowBlock, bool) { + /// the start and returns it: the first block of a fresh route with no seek reaching it lands + /// a `Set` at offset 0 on a lane whose curve there differs from what the lane holds; the first + /// block of a carried route instead seeds each lane's cursor with the curve's value at its + /// first sample and returns that value as the lane's placed curve, which the block entry + /// takes after the cell (D3: the successor places its cursors fresh, and its block entry + /// compares). With no jump lengths or no node span the block has no events, nothing is placed + /// and the curve holds (#1652 NIT-1: production plans always attach both). + fn block( + &mut self, + time: &crate::GraphBlockTime<'_>, + ) -> (FollowBlock, FollowStart, [Option; 2]) { let start = core::mem::replace(&mut self.start, FollowStart::Running); let carried = start == FollowStart::Carried; + let mut placed = [None; 2]; let frames = time.frames(); let Some(lengths) = time.jump_lengths() else { - return (FollowBlock::IDLE, carried); + return (FollowBlock::IDLE, start, placed); }; let jump = core::cmp::min((self.mute_jump)(lengths), crate::ROUTE_RAMP_LENGTH_MAXIMUM); let idle = FollowBlock { @@ -1176,7 +1582,7 @@ impl FollowCursors { jump, }; let Some(span) = time.node_span(self.arrival) else { - return (idle, carried); + return (idle, start, placed); }; let length = i64::from(frames); if frames == 0 @@ -1185,14 +1591,14 @@ impl FollowCursors { .step .is_some_and(|step| step.timeline.checked_add(length).is_none()) { - return (idle, carried); + return (idle, start, placed); } let mut spans = [None; 2]; - for (((slot, cell), state), curve) in spans + for (((slot, cell), state), placed) in spans .iter_mut() .zip(self.cells) .zip(&mut self.states) - .zip(&mut self.curve) + .zip(&mut placed) { let Some(program) = cell.and_then(|cell| self.program.cell(cell as usize)) else { continue; @@ -1200,7 +1606,7 @@ impl FollowCursors { if carried { let value = automation::value_at(program.segments(), span.start); state.seed_target(value); - *curve = value != 0.0; + *placed = Some(value != 0.0); } let step = span .step @@ -1217,7 +1623,7 @@ impl FollowCursors { // the lane holding its curve, with no panic. *slot = automation::NodeSpan::new(span.start, step, frames).ok(); } - (FollowBlock { spans, jump }, carried) + (FollowBlock { spans, jump }, start, placed) } } @@ -1285,9 +1691,9 @@ fn follow_walk( } impl LiveFollowRoute { - /// Read the cell under `snapshot` and, on a target whose bits change, retarget from the - /// coefficients the route is at over the record's ramp (#1652 D2). The record composes with - /// the curve the route has heard. Returns how many values were applied: `0` or `1`. + /// Read the cell under `snapshot` and retarget each column whose target bits change from the + /// coefficients it is at over the record's ramp (#1652 D2). The record composes with the + /// curve the route has heard. Returns how many values were applied: `0` or `1`. #[inline] fn apply_cell(&mut self, snapshot: LiveSnapshot) -> usize { let mut applied = 0; @@ -1298,14 +1704,7 @@ impl LiveFollowRoute { self.follow .compose(&self.transform, self.flags, self.follow.curve); self.mute = mute; - if target.map(f32::to_bits) != self.ramp.target.map(f32::to_bits) { - self.ramp = IndexedRamp::new( - self.ramp.coefficients_at(self.position), - target, - record.length(), - ); - self.position = 0; - } + self.ramps.retarget(target, record.length()); applied = 1; } #[cfg(any(test, feature = "test-support"))] @@ -1314,60 +1713,73 @@ impl LiveFollowRoute { } /// Take the ramp state of the predecessor's live route of the same ID, of either kind - /// (#1652 D3): the ramp, its position and its mute. The cursors do not carry: this plan's - /// own, unplaced, are placed at its first block's node time, which then compares. - fn adopt(&mut self, ramp: IndexedRamp, position: u32, mute: bool) { - self.ramp = ramp; - self.position = position; + /// (#1652 D3): its column ramps (a six-word predecessor's one ramp in both columns), their + /// positions, its mute and the curve it had heard (`[false; 2]` from a six-word one). The + /// cursors do not carry: this plan's own, unplaced, are placed at its first block's node + /// time, after its cell applies with the heard curve, and the block entry then compares. + fn adopt(&mut self, ramps: ColumnRamps, mute: bool, curve: [bool; 2]) { + self.ramps = ramps; self.mute = mute; + self.follow.curve = curve; self.follow.start = FollowStart::Carried; } /// The route op's block entry (#1652 D2): take the block's spans at the tap's node time; read - /// the cell under the block's snapshot and, on a target whose bits change, retarget from the - /// coefficients the route is at over the record's ramp; on a carried route's first block - /// (D3), compare the composed target with the adopted ramp's and, on a bits change, retarget - /// over the session mute length; decide the block's activity. Returns whether the route - /// mixes this block. + /// the cell under the block's snapshot and retarget each column whose bits change over the + /// record's ramp; on a carried route's first block (D3), place the curve at its own node time, + /// compare the composed target with the adopted ramps' and retarget each changed column over + /// the session mute length; decide the block's activity. Returns the offset from which the + /// route mixes this block (`Some(0)`: the whole block), or `None` when it is inactive. /// /// The cell composes with the curve the route has heard (the curve at the block's first - /// sample before that sample's events), and the block's curve events follow in offset order, - /// as the strip's stage orders them (#1651). Inactive only when the route is muted, past its - /// ramp and undelayed at entry and no event of the block leaves it otherwise: the changes are - /// found first on copies of the cursors, so a route that unmutes inside the block mixes, and a - /// muted route with no change moves no bit. An inactive block commits the copies' cursors (no - /// event of it retargets, since every silencing target is `[+0.0; 4]`). + /// sample before that sample's events; on a carried first block the predecessor's, as the + /// uninterrupted plan's block entry would compose it, #1652 MAJOR-3), and the block's curve + /// events follow in offset order, as the strip's stage orders them (#1651). Inactive only + /// when the route is muted, past both ramps and undelayed at entry and no event of the block + /// leaves it otherwise: the changes are found first on copies of the cursors, so a route that + /// opens inside the block mixes from that offset (MAJOR-1: its destination leaves it out of + /// the frames before), and a muted route with no change moves no bit. An inactive block + /// commits the copies' cursors (no event of it retargets, since every silencing target is + /// `[+0.0; 4]`). #[inline] - fn begin_block(&mut self, snapshot: LiveSnapshot, time: &crate::GraphBlockTime<'_>) -> bool { - // The spans first: a carried route's first block places its curve at its own node time - // before anything composes with it. A running or fresh route's curve is unchanged by it. - let (block, carried) = self.follow.block(time); + fn begin_block( + &mut self, + snapshot: LiveSnapshot, + time: &crate::GraphBlockTime<'_>, + ) -> Option { + // The spans first: a carried route's first block places its cursors at its own node time. + // A running or fresh route's curve is unchanged by it. + let (block, start, placed) = self.follow.block(time); self.block = block; self.apply_cell(snapshot); - if carried { - // #1652 D3: after the join's record (if any, through the cell), the successor - // compares its own composed target with the adopted ramp's and retargets over the - // session mute length only on a bits change: the curve's own jump. + if start == FollowStart::Carried { + // #1652 D3: after the join's record (if any, through the cell, with the heard + // curve), the successor takes the curve at its node time and compares its composed + // target with the adopted ramps'; each column whose bits change retargets over the + // session mute length: the curve's own jump. + let automated = self.follow.automated(); + for ((curve, placed), automated) in + self.follow.curve.iter_mut().zip(placed).zip(automated) + { + if let Some(placed) = placed { + *curve = placed; + } else if !automated { + *curve = false; + } + } let (target, mute) = self.follow .compose(&self.transform, self.flags, self.follow.curve); self.mute = mute; - if target.map(f32::to_bits) != self.ramp.target.map(f32::to_bits) { - self.ramp = IndexedRamp::new( - self.ramp.coefficients_at(self.position), - target, - self.block.jump, - ); - self.position = 0; - } + self.ramps.retarget(target, self.block.jump); } - if !(self.mute && self.position >= self.ramp.length && !self.delayed) { - return true; + if !(self.mute && self.ramps.is_settled() && !self.delayed) { + return Some(0); } let mut states = self.follow.states; let mut curve = self.follow.curve; - let mut target = self.ramp.target.map(f32::to_bits); - let mut active = false; + let mut target = self.ramps.target().map(f32::to_bits); + let mut from = None; let (follow, transform, flags, jump) = (&self.follow, &self.transform, self.flags, self.block.jump); follow_walk( @@ -1376,34 +1788,36 @@ impl LiveFollowRoute { &mut states, &mut curve, self.block, - |_, curve, set| { + |offset, curve, set| { let (next, mute) = follow.compose(transform, flags, curve); let next = next.map(f32::to_bits); // A retarget is to a target that does not silence, so it always activates. let settled = next == target || set || jump == 0; target = next; - active |= !(mute && settled); + if from.is_none() && !(mute && settled) { + from = Some(offset); + } }, ); - if !active { + if from.is_none() { self.follow.states = states; self.follow.curve = curve; } - active + from } /// D2: mix the block, retargeting at each curve event whose target's bits change: mix - /// `[0, o)`, retarget from `coefficients_at(position)` over the block's mute jump length (`0` - /// for a seek's `Set`), mix `[o, q)`. The indexed law makes the split exact: the result is - /// the same block split at `o` with the retarget at the second part's entry, bit for bit. + /// `[0, o)`, retarget each changed column from the coefficients it is at over the block's + /// mute jump length (`0` for a seek's `Set`), mix `[o, q)`. The indexed law makes the split + /// exact: the result is the same block split at `o` with the retarget at the second part's + /// entry, bit for bit. #[inline] fn mix(&mut self, left: &mut [f32], right: &mut [f32]) { let frames = core::cmp::min(left.len(), right.len()); let mut from = 0_usize; let automated = self.follow.automated(); let Self { - ramp, - position, + ramps, mute, transform, flags, @@ -1428,34 +1842,28 @@ impl LiveFollowRoute { |offset, curve, set| { let (target, silences) = follow_target(automated, transform, *flags, curve); *mute = silences; - if target.map(f32::to_bits) == ramp.target.map(f32::to_bits) { + if target.map(f32::to_bits) == ramps.target().map(f32::to_bits) { return; } let at = core::cmp::min(offset as usize, frames); - mix_follow_part(left, right, from, at, ramp, position); - *ramp = IndexedRamp::new( - ramp.coefficients_at(*position), - target, - if set { 0 } else { jump }, - ); - *position = 0; + mix_follow_part(left, right, from, at, ramps); + ramps.retarget(target, if set { 0 } else { jump }); from = at; }, ); - mix_follow_part(left, right, from, frames, ramp, position); + mix_follow_part(left, right, from, frames, ramps); } } -/// Mix frames `[from, to)` of the block by `ramp` from `position`, then advance and saturate -/// `position`. An empty or out-of-block range mixes nothing. +/// Mix frames `[from, to)` of the block by `ramps`, then advance and saturate their positions. An +/// empty or out-of-block range mixes nothing. #[inline] fn mix_follow_part( left: &mut [f32], right: &mut [f32], from: usize, to: usize, - ramp: &IndexedRamp, - position: &mut u32, + ramps: &mut ColumnRamps, ) { let (Some(left), Some(right)) = (left.get_mut(from..to), right.get_mut(from..to)) else { return; @@ -1463,9 +1871,7 @@ fn mix_follow_part( if left.is_empty() { return; } - route_mix_ramp_block::(left, right, ramp, *position); - let frames = u32::try_from(left.len()).unwrap_or(u32::MAX); - *position = core::cmp::min(position.saturating_add(frames), ramp.length); + ramps.mix(left, right); } // REALTIME_POLICY_END @@ -4184,13 +4590,15 @@ fn execute_op( } } // #1652 D2: a following route reads its cell and finds its block's curve changes first, so a - // route inactive at block entry that unmutes inside the block mixes it. + // route inactive at block entry that unmutes inside the block mixes from that offset, and its + // destination sums it from there on (MAJOR-1). if let NodeKind::LiveFollowRoute(live) = &mut op.kind { - let active = live.begin_block(snapshot, block_time); + let from = live.begin_block(snapshot, block_time); if let Some((activity, _)) = routes.as_mut() { - activity.set_active(live.route as usize, active); + activity.set_active(live.route as usize, from.is_some()); + activity.set_from(live.route as usize, from.unwrap_or(0)); } - if !active { + if from.is_none() { return Ok(()); } } @@ -4235,11 +4643,7 @@ fn execute_op( // A route destination reduces only its active inputs, with the first input in edge order // owning the store (issue #1217 D3). if !op.inputs.is_empty() || !matches!(op.kind, NodeKind::Bound(_)) { - let gated = routes.and_then(|(activity, unit)| { - activity - .destination_of(unit) - .map(|pairs| (pairs, &*activity.active)) - }); + let gated = routes.and_then(|(activity, unit)| activity.gated(unit)); match (host.as_mut(), gated) { (None, None) => { reduce_plane(arena, 0, output, &op.inputs); @@ -4250,11 +4654,11 @@ fn execute_op( reduce_plane_into(arena, 0, output, left, &op.inputs); reduce_plane_into(arena, 1, output, right, &op.inputs); } - (None, Some((pairs, active))) => { - reduce_gated(arena, output, &op.inputs, pairs, active); + (None, Some(gated)) => { + reduce_gated(arena, output, &op.inputs, gated); } - (Some(host), Some((pairs, active))) => { - reduce_gated_into(arena, output, host.planes_mut(), &op.inputs, pairs, active); + (Some(host), Some(gated)) => { + reduce_gated_into(arena, output, host.planes_mut(), &op.inputs, gated); } } } @@ -5261,110 +5665,159 @@ fn live_route_mut(units: &mut [RuntimeUnit], unit: u32) -> Option<&mut LiveRoute } } -/// One move of a plan's live-send carry section (#1284 D2) and the comparison it leaves for the -/// successor's first block (#1652 D3). +/// Whether runtime unit `unit` holds a following route's op (#1652 D3). Allocation-free. +fn is_follow_route_unit(units: &[RuntimeUnit], unit: u32) -> bool { + matches!( + units.get(unit as usize), + Some(RuntimeUnit::Op(op)) if matches!(op.kind, NodeKind::LiveFollowRoute(_)) + ) +} + +/// One move of a plan's live-send carry section (#1284 D2): the route's unit in the successor and +/// in the predecessor. Its old eight bytes: the comparison a six-word successor of a following +/// predecessor leaves for its first block lives in the side table [`RouteCarryCheck`] (#1652 +/// MINOR-3), which a session without stored mute automation never allocates. #[derive(Clone, Copy)] pub(crate) struct RouteCarryStep { - pub(crate) step: crate::GraphRouteMove, - /// Set at the swap block when a six-word successor took the ramp of a following predecessor - /// whose target's bits differ from the successor's own prepared target; taken by - /// [`GraphRuntime::finish_route_carry`] at the successor's first block. - check: Option, + successor: u32, + predecessor: u32, } impl RouteCarryStep { - /// A move with no comparison pending. Control side. + /// A move. Control side. pub(crate) const fn new(step: crate::GraphRouteMove) -> Self { + Self { + successor: step.successor, + predecessor: step.predecessor, + } + } +} + +/// One entry of a carry section's side table (#1652 D3, MINOR-3): a move whose predecessor row +/// is a following route, by its index in the move table, and the comparison the swap block may +/// leave there for a six-word successor's first block. One per such move, allocated by +/// `install_route_carry` on the control thread, in move order. +#[derive(Clone, Copy)] +pub(crate) struct RouteCarryCheck { + step: u32, + check: Option, +} + +impl RouteCarryCheck { + /// The slot of move `step`, empty. Control side. + pub(crate) const fn new(step: u32) -> Self { Self { step, check: None } } } /// What a six-word successor of a following predecessor compares at its first block (#1652 D3, -/// the curve removed): its own prepared target and mute, and the mute row's jump-length rule the -/// predecessor's source carried, which a six-word route does not hold. +/// the curve removed): its own prepared target and mute, the columns the predecessor's heard +/// curve zeroed (so a record the join wrote composes with that curve first, MAJOR-3), and the +/// mute row's jump-length rule the predecessor's source carried, which a six-word route does not +/// hold. #[derive(Clone, Copy)] struct SixWordCarryCheck { prepared: [f32; 4], mute: bool, + zeroed: [bool; 2], mute_jump: crate::GraphMuteJumpLength, } -/// One six-word successor's comparison at its first block (#1652 D3, the curve removed): its cell -/// first, under the block's snapshot (the join's record, if any, is this plan's own value and -/// governs); without a record, [`LiveRoute::retarget_carried`] to its prepared target over the -/// session mute length. Allocation-, lock- and panic-free. +/// One six-word successor's comparison at its first block (#1652 D3, the curve removed), in the +/// order the uninterrupted plan makes it (MAJOR-3): its cell first, under the block's snapshot; +/// a record (the join's, this plan's own value) composes with the curve the predecessor had +/// heard and retargets over its own ramp, as a block entry before the curve's events would; then +/// the curve's removal reopens the route to the record's own target (or, with no record, to the +/// prepared target) over the session mute length, only on a bits change. A six-word route holds +/// one ramp, so that reopening restarts it whole. Allocation-, lock- and panic-free. fn finish_six_word_carry( live: &mut LiveRoute, check: SixWordCarryCheck, snapshot: LiveSnapshot, lengths: Option, ) { - if live.apply_pending(snapshot) != 0 { - return; - } let length = lengths.map_or(0, |lengths| { core::cmp::min((check.mute_jump)(lengths), crate::ROUTE_RAMP_LENGTH_MAXIMUM) }); + if let Some((target, mute)) = live.apply_pending_heard(snapshot, check.zeroed) { + live.retarget_carried(target, mute, length); + return; + } live.retarget_carried(check.prepared, check.mute, length); } /// One live send's carry at the swap block (#1284 D2, #1652 D3), whatever the kind on either side. /// The predecessor's pending value is applied under every snapshot first, as its next block -/// would apply it; then the successor takes the ramp, its position and the mute. +/// would apply it; then the successor takes the ramps, their positions, the mute and (into a +/// following route) the curve the predecessor had heard. /// /// * six -> six: [`LiveRoute::adopt`], exactly as before #1652. -/// * either -> seven: [`LiveFollowRoute::adopt`]; its first block places its own cursors at its -/// own node time and compares ([`LiveFollowRoute::begin_block`]). -/// * seven -> six: [`LiveRoute::adopt_state`]; when the adopted target's bits differ from the -/// successor's prepared target, the comparison is left in `check` for the successor's first -/// block, which knows the block's jump lengths. Returns whether it left one. +/// * either -> seven: [`LiveFollowRoute::adopt`]; its first block applies its cell with the heard +/// curve, places its own cursors at its own node time and compares +/// ([`LiveFollowRoute::begin_block`]). +/// * seven -> six: [`LiveRoute::adopt_state`] of [`ColumnRamps::one_ramp`]; when the adopted +/// target's bits differ from the successor's prepared target, or the heard curve zeroes a +/// column (a record the join wrote must compose with it), the comparison is left in `check` for +/// the successor's first block, which knows the block's jump lengths. Returns whether it left +/// one. /// /// Allocation-, lock- and panic-free: bounded copies. fn carry_route( into: CarriedRoute<'_>, from: CarriedRoute<'_>, - check: &mut Option, + check: Option<&mut Option>, ) -> bool { - *check = None; - let (ramp, position, mute, mute_jump) = match from { + let (ramps, mute, follow) = match from { CarriedRoute::Six(from) => { from.apply_pending(LiveSnapshot::ALL); if let CarriedRoute::Six(into) = into { into.adopt(from); return false; } - (from.ramp, from.position, from.mute, None) + ( + ColumnRamps::shared(from.ramp, from.position), + from.mute, + None, + ) } CarriedRoute::Seven(from) => { from.apply_cell(LiveSnapshot::ALL); + let zeroed = from.follow.heard_zeroed(from.flags); ( - from.ramp, - from.position, + from.ramps, from.mute, - Some(from.follow.mute_jump), + Some((from.follow.curve, zeroed, from.follow.mute_jump)), ) } }; match into { CarriedRoute::Seven(into) => { - into.adopt(ramp, position, mute); + into.adopt(ramps, mute, follow.map_or([false; 2], |(curve, ..)| curve)); false } CarriedRoute::Six(into) => { // A six-word successor has rendered no block at the swap: its ramp is the settled // prepared target bind gave it, and its mute is the prepared gate's. let (prepared, prepared_mute) = (into.ramp.target, into.mute); + let (ramp, position) = ramps.one_ramp(); into.adopt_state(ramp, position, mute); - let Some(mute_jump) = mute_jump else { + let Some((_, zeroed, mute_jump)) = follow else { return false; }; - if prepared.map(f32::to_bits) == ramp.target.map(f32::to_bits) { + if prepared.map(f32::to_bits) == ramp.target.map(f32::to_bits) && zeroed == [false; 2] { into.mute = prepared_mute; return false; } + // `can_carry_live_routes` refused a hand-over whose seven -> six move has no slot. + let Some(check) = check else { + debug_assert!(false, "a seven -> six move has its side-table slot"); + into.retarget_carried(prepared, prepared_mute, 0); + return false; + }; *check = Some(SixWordCarryCheck { prepared, mute: prepared_mute, + zeroed, mute_jump, }); true @@ -5885,15 +6338,24 @@ impl crate::runtime::Runtime { /// Whether every move of `moves` names a live route unit of this (successor) runtime and one /// of `predecessor`, of either kind on either side (#1284 D2, #1652 D3). Checked before /// anything moves, so the carry is all or nothing. Allocation-free. + /// + /// A seven -> six move (a following predecessor, a six-word successor) must have its slot in + /// the side table `checks` (#1652 MINOR-3), so its comparison cannot be lost at the swap. pub(crate) fn can_carry_live_routes( &self, predecessor: &Self, moves: &[RouteCarryStep], + checks: &[RouteCarryCheck], ) -> bool { - moves.iter().all(|step| { - is_live_route_unit(&self.units, step.step.successor) - && is_live_route_unit(&predecessor.units, step.step.predecessor) - }) + let mut slots = checks.iter().map(|slot| slot.step).peekable(); + moves.iter().zip(0_u32..).all(|(step, index)| { + let slotted = slots.next_if_eq(&index).is_some(); + is_live_route_unit(&self.units, step.successor) + && is_live_route_unit(&predecessor.units, step.predecessor) + && (slotted + || !is_follow_route_unit(&predecessor.units, step.predecessor) + || is_follow_route_unit(&self.units, step.successor)) + }) && slots.next().is_none() } /// Run the live-send section of a carry program at the swap block (#1284 D2, #1652 D3): for @@ -5905,19 +6367,28 @@ impl crate::runtime::Runtime { pub(crate) fn carry_live_routes( &mut self, predecessor: &mut Self, - moves: &mut [RouteCarryStep], + moves: &[RouteCarryStep], + checks: &mut [RouteCarryCheck], ) -> bool { let mut pending = false; - for step in moves.iter_mut() { + let mut slots = checks.iter_mut().peekable(); + for (step, index) in moves.iter().zip(0_u32..) { + let slot = slots + .next_if(|slot| slot.step == index) + .map(|slot| &mut slot.check); let (Some(into), Some(from)) = ( - carried_route_mut(&mut self.units, step.step.successor), - carried_route_mut(&mut predecessor.units, step.step.predecessor), + carried_route_mut(&mut self.units, step.successor), + carried_route_mut(&mut predecessor.units, step.predecessor), ) else { continue; }; // Admission validated the cell's words; the count of values this applies is the // drain's own, added to the session's `live_values_superseded` as at a block. - pending |= carry_route(into, from, &mut step.check); + let slot = slot.map(|check| { + *check = None; + check + }); + pending |= carry_route(into, from, slot); } pending } @@ -5931,15 +6402,19 @@ impl crate::runtime::Runtime { /// once. Bounded by `moves.len()`; allocation-, lock- and panic-free. pub(crate) fn finish_route_carry( &mut self, - moves: &mut [RouteCarryStep], + moves: &[RouteCarryStep], + checks: &mut [RouteCarryCheck], snapshot: LiveSnapshot, lengths: Option, ) { - for step in moves.iter_mut() { - let Some(check) = step.check.take() else { + for slot in checks.iter_mut() { + let Some(check) = slot.check.take() else { continue; }; - if let Some(live) = live_route_mut(&mut self.units, step.step.successor) { + let Some(step) = moves.get(slot.step as usize) else { + continue; + }; + if let Some(live) = live_route_mut(&mut self.units, step.successor) { finish_six_word_carry(live, check, snapshot, lengths); } } @@ -8111,8 +8586,14 @@ pub(crate) fn build_sequential( &retired, fold.as_ref().map(|fold| fold.master_op), ) - .map(|(activity, delayed)| { + .map(|(mut activity, delayed)| { bind_live_routes(&mut units, &activity, &delayed); + // #1652 MAJOR-1: the activation offsets, only for a plan with a following route. + if units.iter().any(|unit| { + matches!(unit, RuntimeUnit::Op(op) if matches!(op.kind, NodeKind::LiveFollowRoute(_))) + }) { + activity.from = vec![0; activity.active.len()].into_boxed_slice(); + } activity }); let live_routes = live_route_rows(&units, &route_gates); @@ -8212,9 +8693,9 @@ fn live_route_rows( }; // #1652 D3: a following route is listed as a six-word one is, so the successor's // join carries it by route ID whatever its kind in either plan. - let route = match &op.kind { - NodeKind::LiveRoute(live) => live.route, - NodeKind::LiveFollowRoute(live) => live.route, + let (route, following) = match &op.kind { + NodeKind::LiveRoute(live) => (live.route, false), + NodeKind::LiveFollowRoute(live) => (live.route, true), _ => return None, }; let GraphNodeId::Route { route_id } = &route_gates.get(route as usize)?.0 else { @@ -8223,6 +8704,7 @@ fn live_route_rows( Some(crate::GraphLiveRoute { route_id: Box::from(route_id.as_str()), unit: u32::try_from(unit).expect("a unit index fits the op index width"), + following, }) }) .collect() @@ -8330,6 +8812,7 @@ fn route_activity( units: slots.into_boxed_slice(), destinations: destinations.into_boxed_slice(), inputs: inputs.into_boxed_slice(), + from: Box::default(), }), delayed, )) @@ -8376,6 +8859,13 @@ pub(crate) fn route_activity_largest_bytes(routes: u64, nodes: u64) -> Option Option { + routes.checked_mul(u64::try_from(core::mem::size_of::()).ok()?) +} + /// The bytes bind allocates for a [`RouteActivity`], bounded from what a compile knows: `routes` /// prepared routes over a graph of `nodes` nodes (issue #1217 D6). /// @@ -17159,7 +17649,10 @@ mod follow_route_op_tests { //! in-block retarget (gate 3), the activity of a route that unmutes inside a block, the //! event's time at the tap's arrival, the redundant-retarget guard and a seek. The route op is //! not banked and mixes at `FrameLane`, so no case depends on a bank width. - use super::{FrameLane, IndexedRamp, LiveFollowRoute, route_mix_ramp_block}; + use super::{ + ColumnRamps, FrameLane, IndexedRamp, LiveFollowRoute, route_mix_column_ramps_block, + route_mix_ramp_block, + }; use crate::follow_route_tests::{mute_source, steps}; use crate::{ FollowRouteControlRecord, GraphBlockTime, GraphJumpLengths, GraphPreparedSourceSetDriver, @@ -17170,6 +17663,12 @@ mod follow_route_op_tests { /// The session's mute jump length in these cases. const MUTE: u32 = 48; + /// The block lengths every case renders with. + const LENGTHS: GraphJumpLengths = GraphJumpLengths { + fader_samples: 480, + mute_samples: MUTE, + pan_samples: 960, + }; /// The live record's ramp at the block entry, long enough to run through every block. const RECORD_RAMP: u32 = 256; /// The block's first source-read sample `r + ΣP`. @@ -17251,6 +17750,7 @@ mod follow_route_op_tests { source, 0, 0, + 0, ); let route = Box::new(LiveFollowRoute::new( transform, @@ -17286,19 +17786,25 @@ mod follow_route_op_tests { left: &mut [f32], right: &mut [f32], ) -> bool { + self.entry(first, seek, left, right).is_some() + } + + /// [`Self::block`], returning the offset the route mixes from (`None`: inactive). + fn entry( + &mut self, + first: u64, + seek: Option, + left: &mut [f32], + right: &mut [f32], + ) -> Option { let timeline = Timeline { seek }; - let lengths = GraphJumpLengths { - fader_samples: 480, - mute_samples: MUTE, - pan_samples: 960, - }; let frames = u32::try_from(left.len()).expect("frames"); - let time = GraphBlockTime::new(Some(first), frames, Some(lengths), Some(&timeline)); - let active = self.route.begin_block(self.gate.snapshot(), &time); - if active { + let time = GraphBlockTime::new(Some(first), frames, Some(LENGTHS), Some(&timeline)); + let from = self.route.begin_block(self.gate.snapshot(), &time); + if from.is_some() { self.route.mix(left, right); } - active + from } } @@ -17322,7 +17828,9 @@ mod follow_route_op_tests { } /// The reference: mix `[0, o)` by `first` from position 0, then retarget to `target` over - /// `length` from `first`'s coefficients at `o`, and mix `[o, q)` from position 0. + /// `length` from `first`'s coefficients at `o`, and mix `[o, q)` from position 0. Only the + /// columns whose target bits change retarget (R18-5): a column whose bits hold keeps `first` + /// at position `o`. Built from the kernels and `IndexedRamp` alone. fn split_reference( first: IndexedRamp, o: usize, @@ -17336,7 +17844,20 @@ mod follow_route_op_tests { route_mix_ramp_block::(left_a, right_a, &first, 0); let at = u32::try_from(o).expect("offset"); let second = IndexedRamp::new(first.coefficients_at(at), target, length); - route_mix_ramp_block::(left_b, right_b, &second, 0); + let changes = |indices: [usize; 2]| { + indices + .iter() + .any(|&index| target[index].to_bits() != first.target[index].to_bits()) + }; + let ramps = [ + if changes([0, 2]) { second } else { first }, + if changes([1, 3]) { second } else { first }, + ]; + let positions = [ + if changes([0, 2]) { 0 } else { at }, + if changes([1, 3]) { 0 } else { at }, + ]; + route_mix_column_ramps_block::(left_b, right_b, &ramps, positions); bits((&left, &right)) } @@ -17415,9 +17936,10 @@ mod follow_route_op_tests { ); let (mut left, mut right) = input(frames); - assert!( - route.block(FIRST + frames as u64, None, &mut left, &mut right), - "o {o}: an unmute inside the block activates it" + assert_eq!( + route.entry(FIRST + frames as u64, None, &mut left, &mut right), + Some(o as u32), + "o {o}: an unmute inside the block activates it from its offset" ); let expected = split_reference( IndexedRamp::settled([0.0; 4]), @@ -17490,7 +18012,11 @@ mod follow_route_op_tests { bits((&left, &right)), bits((&expected_left, &expected_right)) ); - assert_eq!(route.route.ramp, ramp, "the ramp in flight is kept"); + assert_eq!( + route.route.ramps.one_ramp(), + (ramp, frames as u32), + "the ramp in flight is kept" + ); } /// A seek that reaches the tap at offset `s` into a span where the source's left mute is on @@ -17518,7 +18044,7 @@ mod follow_route_op_tests { frames, ); assert_eq!(bits((&left, &right)), expected); - assert_eq!(route.route.ramp.length, 0, "a step, no ramp"); + assert_eq!(route.route.ramps.ramps[0].length, 0, "a step, no ramp"); } /// The bound route composes its target from the open transform, the seeded flags and each @@ -17536,7 +18062,7 @@ mod follow_route_op_tests { ([Some(&off[..]), Some(&on[..])], [false, true]), ] { let route = Fixture::new(lanes, 0, OPEN); - assert_eq!(route.route.ramp, IndexedRamp::settled(gated(OPEN, zeroed))); + assert_eq!(route.route.ramps, ColumnRamps::settled(gated(OPEN, zeroed))); assert_eq!( route.route.flags, RouteFollowFlags { @@ -17558,19 +18084,12 @@ mod follow_route_op_tests { mod carry { use super::super::{CarriedRoute, LiveRoute, carry_route, finish_six_word_carry}; use super::{ - FIRST, Fixture, FrameLane, IndexedRamp, MOVED, MUTE, OPEN, RECORD_RAMP, bits, gated, - input, route_mix_ramp_block, + ColumnRamps, FIRST, Fixture, FrameLane, IndexedRamp, LENGTHS, MOVED, MUTE, OPEN, + RECORD_RAMP, bits, gated, input, route_mix_column_ramps_block, route_mix_ramp_block, }; use crate::follow_route_tests::steps; - use crate::{GraphJumpLengths, GraphRouteControlProducer, RouteControlRecord}; + use crate::{GraphRouteControlProducer, RouteControlRecord}; use engine::realtime::{GatePin, LiveValuesSuperseded, RevisionGate}; - - /// The block lengths [`Fixture::block`] renders with. - const LENGTHS: GraphJumpLengths = GraphJumpLengths { - fader_samples: 480, - mute_samples: MUTE, - pan_samples: 960, - }; /// Frames the predecessor renders before the swap: the swap lands mid-ramp. const BEFORE: usize = 100; /// Frames of the swap block. @@ -17683,40 +18202,47 @@ mod follow_route_op_tests { let pending = carry_route( CarriedRoute::Seven(&mut successor.route), CarriedRoute::Six(&mut predecessor.route), - &mut check, + Some(&mut check), ); assert!(successor.block(FIRST + BEFORE as u64, None, &mut left, &mut right)); assert!(!pending && check.is_none()); assert_eq!(bits((&left, &right)), expected, "(a) a pure re-adoption"); - assert_eq!(successor.route.ramp, ramp, "(a) the ramp is kept"); - assert_eq!(successor.route.position, (BEFORE + AFTER) as u32); + assert_eq!( + successor.route.ramps, + ColumnRamps::shared(ramp, (BEFORE + AFTER) as u32), + "(a) the ramp and its position are kept" + ); // (b) The curve stepped on at timeline 500, before the swap block at 1100. let on = steps(&[0, 500], &[0.0, 1.0], 1 << 20); let mut predecessor = six_mid_ramp(); let at = predecessor.route.ramp.coefficients_at(BEFORE as u32); + let moving = predecessor.route.ramp; let mut successor = Fixture::new([Some(&on), None], 0, MOVED); carry_route( CarriedRoute::Seven(&mut successor.route), CarriedRoute::Six(&mut predecessor.route), - &mut None, + None, ); let (mut left, mut right) = input(AFTER); assert!(successor.block(FIRST + BEFORE as u64, None, &mut left, &mut right)); + // The left column (its gate changed) takes the curve's own jump from where it is; the + // right column's bits hold, so it keeps its move (R18-5). let reopened = IndexedRamp::new(at, gated(MOVED, [true, false]), MUTE); let (mut expected_left, mut expected_right) = input(AFTER); - route_mix_ramp_block::( + route_mix_column_ramps_block::( &mut expected_left, &mut expected_right, - &reopened, - 0, + &[reopened, moving], + [0, BEFORE as u32], ); assert_eq!( bits((&left, &right)), bits((&expected_left, &expected_right)), "(b) the curve's own jump from where the route is" ); - assert_eq!(successor.route.ramp, reopened); + assert_eq!(successor.route.ramps.ramps[0].length, MUTE); + assert_eq!(successor.route.ramps.ramps[1], moving); } /// Gate 4 (seven -> seven): a following route mid-ramp keeps its ramp and position into a @@ -17729,8 +18255,9 @@ mod follow_route_op_tests { fn seven_to_seven_mid_ramp_keeps_its_position() { let open = steps(&[0], &[0.0], 1 << 20); let mut predecessor = seven_mid_ramp(&open); - let ramp = predecessor.route.ramp; - assert_eq!(predecessor.route.position, BEFORE as u32); + let ramps = predecessor.route.ramps; + let (ramp, position) = ramps.one_ramp(); + assert_eq!(position, BEFORE as u32); let expected = twin_block(ramp); let mut successor = Fixture::new([Some(&open), None], 0, MOVED); @@ -17738,13 +18265,16 @@ mod follow_route_op_tests { let pending = carry_route( CarriedRoute::Seven(&mut successor.route), CarriedRoute::Seven(&mut predecessor.route), - &mut None, + None, ); assert!(successor.block(FIRST + BEFORE as u64, None, &mut left, &mut right)); assert!(!pending); assert_eq!(bits((&left, &right)), expected); - assert_eq!(successor.route.ramp, ramp); - assert_eq!(successor.route.position, (BEFORE + AFTER) as u32); + assert_eq!(successor.route.ramps.ramps, ramps.ramps); + assert_eq!( + successor.route.ramps.positions, + [(BEFORE + AFTER) as u32; 2] + ); } /// Gate 4 (b) (seven -> six, the rebuild removes the mute entry): a following route the @@ -17770,7 +18300,7 @@ mod follow_route_op_tests { let pending = carry_route( CarriedRoute::Six(&mut successor.route), CarriedRoute::Seven(&mut predecessor.route), - &mut check, + Some(&mut check), ); if let Some(check) = check { finish_six_word_carry( @@ -17797,5 +18327,433 @@ mod follow_route_op_tests { ); assert!(!successor.route.mute); } + + /// #1652 U2a fold 1 (batch 18): the activation offset at the destination (MAJOR-1), the + /// per-column ramps (R18-5: MAJOR-2, MINOR-1) and the carried record's order (MAJOR-3). + mod fold { + use super::super::super::{ + CompensationDelay, DisjointArena, GraphRuntimeSplitPairProcessor, NodeKind, + ROUTE_DESTINATION, RouteActivity, RouteInput, RuntimeOp, TrackDelayLine, + execute_op, + }; + use super::super::{LENGTHS, Timeline}; + use super::{ + AFTER, BEFORE, CarriedRoute, ColumnRamps, FIRST, Fixture, FrameLane, IndexedRamp, + MOVED, MUTE, OPEN, RECORD_RAMP, Six, bits, carry_route, finish_six_word_carry, + gated, input, route_mix_column_ramps_block, route_mix_ramp_block, steps, + }; + use crate::GraphBlockTime; + use core::num::NonZeroUsize; + + /// Buffers of the two-op graph: the source, the route's output, the destination and a + /// second input of the destination. + /// (Buffer 0 is the arena's silence buffer.) + const SOURCE: u32 = 1; + const ROUTED: u32 = 2; + const DESTINATION: u32 = 3; + const OTHER: u32 = 4; + + /// A following route op (unit 0) into one destination op (unit 1), as bind lays them + /// out, with the activity table the executor carries beside them. + struct Graph { + route: RuntimeOp, + destination: RuntimeOp, + activity: RouteActivity, + gate: engine::realtime::RevisionGate, + } + + impl Graph { + /// `fixture`'s route as unit 0, reading `SOURCE`; the destination reads `inputs`. + /// The route's activity bit starts set, as a prepared open route's does. + fn new(fixture: Fixture, inputs: &[u32], position: u32) -> Self { + let Fixture { + mut route, gate, .. + } = fixture; + route.route = 0; + let op = |inputs: &[u32], output, kind| RuntimeOp { + inputs: inputs.into(), + staged: Box::default(), + sidechain: None, + output, + kind, + split_pair: None, + observers: Box::default(), + }; + Self { + route: op(&[SOURCE], ROUTED, NodeKind::LiveFollowRoute(route)), + destination: op(inputs, DESTINATION, NodeKind::Identity), + activity: RouteActivity { + active: Box::new([true]), + units: Box::new([0, ROUTE_DESTINATION]), + destinations: Box::new([(0, 1)]), + inputs: Box::new([RouteInput { position, route: 0 }]), + from: Box::new([0]), + }, + gate, + } + } + + /// One block of the two ops over `source` (and `other`, the destination's second + /// input), whose first source-read sample is `first`. The route's output buffer + /// starts as stale words (`7.0`), which an inactive route leaves unread. Returns + /// the destination's planes. + fn block( + &mut self, + first: u64, + source: (&[f32], &[f32]), + other: (&[f32], &[f32]), + ) -> (Vec, Vec) { + let frames = source.0.len(); + let mut arena = DisjointArena::try_new( + NonZeroUsize::new(2).expect("planes"), + NonZeroUsize::new(frames).expect("frames"), + 5, + ) + .expect("arena"); + for (plane, words) in [(0, source.0), (1, source.1)] { + arena.write(plane, SOURCE).copy_from_slice(words); + arena.write(plane, ROUTED).fill(7.0); + } + for (plane, words) in [(0, other.0), (1, other.1)] { + arena.write(plane, OTHER).copy_from_slice(words); + } + let timeline = Timeline { seek: None }; + let time = GraphBlockTime::new( + Some(first), + u32::try_from(frames).expect("frames"), + Some(LENGTHS), + Some(&timeline), + ); + let snapshot = self.gate.snapshot(); + for (op, unit) in [(&mut self.route, 0), (&mut self.destination, 1)] { + let (delays, lines, pairs): ( + &mut [CompensationDelay], + &mut [TrackDelayLine], + &mut [Box], + ) = (&mut [], &mut [], &mut []); + execute_op( + op, + &mut arena, + delays, + lines, + pairs, + 0, + snapshot, + None, + Some((&mut self.activity, unit)), + &time, + ) + .expect("the block renders"); + } + ( + arena.read(0, DESTINATION).to_vec(), + arena.read(1, DESTINATION).to_vec(), + ) + } + } + + /// Negative audio on both planes (`-(1 + i % 7) / 3`): a `+0.0` coefficient times it + /// is `-0.0`. + fn negative(frames: usize) -> (Vec, Vec) { + let plane = |salt: usize| { + (0..frames) + .map(|i| -(1.0 + ((i + salt) % 7) as f32) / 3.0) + .collect::>() + }; + (plane(0), plane(3)) + } + + /// MAJOR-1 gate. A following route muted by both source lanes, settled and + /// undelayed, is inactive in a block with no change (its destination never reads + /// its stale buffer), and in a block whose curves open it at offset `o` its + /// destination equals the same block split at `o` bit for bit: the frames before `o` + /// leave the route out. With negative feeds, the route's `[0, o)` mix of its silent + /// coefficients is `-0.0`, so a destination that summed it would differ: as the sole + /// input (`+0.0` against `-0.0`), first beside a `-0.0` input, and second after one + /// (with positive feeds, `+0.0` after `-0.0`). + /// + /// Test value: red if the destination sums an opening route over the whole block (the + /// signed-zero leak the brief's MAJOR-1 names), or if the route op's activity bit + /// is not written each block (mutation M2: the inactive block reads the stale words). + #[test] + fn an_opening_route_is_left_out_of_its_destination_before_its_offset() { + let frames = 128_usize; + // (case, the destination's inputs, the route's position, negative feeds, the + // other input's word, the destination's word while the route is left out) + for (case, inputs, position, negative_feed, other_word, idle_word) in [ + ("sole", &[ROUTED][..], 0, true, 0.0, 0.0), + ("first", &[ROUTED, OTHER][..], 0, true, -0.0, 0.0), + ("second", &[OTHER, ROUTED][..], 1, false, -0.0, -0.0), + ] { + for o in [1_usize, 63, 64, 127] { + let open = FIRST + frames as u64 + o as u64; + let curve = steps(&[0, open], &[1.0, 0.0], 1 << 20); + let feed = if negative_feed { + negative(frames) + } else { + input(frames) + }; + let feed = if negative_feed { + feed + } else { + ( + feed.0.iter().map(|x| x.abs() + 0.5).collect(), + feed.1.iter().map(|x| x.abs() + 0.5).collect(), + ) + }; + let other = (vec![other_word; frames], vec![other_word; frames]); + let fixture = || Fixture::new([Some(&curve), Some(&curve)], 0, OPEN); + + let mut whole = Graph::new(fixture(), inputs, position); + let idle = whole.block(FIRST, (&feed.0, &feed.1), (&other.0, &other.1)); + let idle_expected = vec![idle_word; frames]; + assert_eq!( + bits((&idle.0, &idle.1)), + bits((&idle_expected, &idle_expected)), + "{case}, o {o}: an inactive route is never read" + ); + assert!(!whole.activity.active[0], "{case}: the bit is written"); + let got = whole.block( + FIRST + frames as u64, + (&feed.0, &feed.1), + (&other.0, &other.1), + ); + assert_eq!(whole.activity.from[0], o as u32, "{case}: the offset"); + + let mut split = Graph::new(fixture(), inputs, position); + split.block(FIRST, (&feed.0, &feed.1), (&other.0, &other.1)); + let first = split.block( + FIRST + frames as u64, + (&feed.0[..o], &feed.1[..o]), + (&other.0[..o], &other.1[..o]), + ); + let second = split.block( + open, + (&feed.0[o..], &feed.1[o..]), + (&other.0[o..], &other.1[o..]), + ); + let expected_left: Vec = + first.0.iter().chain(&second.0).copied().collect(); + let expected_right: Vec = + first.1.iter().chain(&second.1).copied().collect(); + assert_eq!( + bits((&got.0, &got.1)), + bits((&expected_left, &expected_right)), + "{case}, o {o}: the block split at its offset" + ); + } + } + } + + /// MINOR-1 gate (R18-5). The source's left mute steps on at offset 40 and its right + /// mute 30 samples later, at 70. Each column ramps over the session mute length from + /// its own event: the left column ends at 88, the right at 118. The block equals the + /// kernel run part by part with those two ramps. + /// + /// Test value: red on one shared ramp (the right lane's event restarts the left + /// column's move at 70, stretching it to 118: the in-plan leak MINOR-1 names). + #[test] + fn per_lane_steps_thirty_samples_apart_each_end_with_the_mute_length() { + let frames = 128_usize; + let left_curve = steps(&[0, FIRST + 40], &[0.0, 1.0], 1 << 20); + let right_curve = steps(&[0, FIRST + 70], &[0.0, 1.0], 1 << 20); + let mut route = Fixture::new([Some(&left_curve), Some(&right_curve)], 0, OPEN); + let (mut left, mut right) = input(frames); + assert!(route.block(FIRST, None, &mut left, &mut right)); + + let open = gated(OPEN, [false; 2]); + let left_ramp = IndexedRamp::new(open, gated(OPEN, [true, false]), MUTE); + let right_ramp = IndexedRamp::new(open, [0.0; 4], MUTE); + let settled = IndexedRamp::settled(open); + let (mut want_left, mut want_right) = input(frames); + let parts: [(usize, usize, [IndexedRamp; 2], [u32; 2]); 3] = [ + (0, 40, [settled; 2], [0; 2]), + (40, 70, [left_ramp, settled], [0, 0]), + (70, 128, [left_ramp, right_ramp], [30, 0]), + ]; + for (from, to, ramps, positions) in parts { + route_mix_column_ramps_block::( + &mut want_left[from..to], + &mut want_right[from..to], + &ramps, + positions, + ); + } + assert_eq!(bits((&left, &right)), bits((&want_left, &want_right))); + let ramps = route.route.ramps; + assert_eq!( + [ramps.ramps[0].length, ramps.ramps[1].length], + [MUTE; 2], + "both columns ramp over the strip's mute length" + ); + assert_eq!(ramps.positions, [MUTE, MUTE], "both ended inside the block"); + assert!(route.route.mute); + } + + /// MAJOR-2 gate (R18-5). A following route whose left column is open mid-way through + /// a live record's move (position 100 of 256) is carried into a following successor + /// whose right lane's curve is on at the swap: the right column takes the curve's + /// jump over the mute length, and the left column keeps its move, `256 - 100` frames + /// still to run. The swap block equals the never-swapped twin's, and the left column + /// equals the original record ramp continued. + /// + /// Test value: red on one shared ramp (the right column's gate change restarts the + /// left column's move over the mute length at the swap). + #[test] + fn an_open_columns_move_keeps_its_length_across_a_gate_changing_carry() { + let open = steps(&[0], &[0.0], 1 << 20); + let on_at_swap = steps(&[0, FIRST + BEFORE as u64], &[0.0, 1.0], 1 << 20); + let lanes = [Some(&open[..]), Some(&on_at_swap[..])]; + let moving = |route: &mut Fixture| { + route.write(MOVED, RECORD_RAMP); + let (mut left, mut right) = input(BEFORE); + assert!(route.block(FIRST, None, &mut left, &mut right)); + }; + let mut twin = Fixture::new(lanes, 0, OPEN); + moving(&mut twin); + let (mut twin_left, mut twin_right) = input(AFTER); + assert!(twin.block(FIRST + BEFORE as u64, None, &mut twin_left, &mut twin_right)); + + let mut predecessor = Fixture::new(lanes, 0, OPEN); + moving(&mut predecessor); + let mut successor = Fixture::new(lanes, 0, MOVED); + assert!(!carry_route( + CarriedRoute::Seven(&mut successor.route), + CarriedRoute::Seven(&mut predecessor.route), + None, + )); + let (mut left, mut right) = input(AFTER); + assert!(successor.block(FIRST + BEFORE as u64, None, &mut left, &mut right)); + assert_eq!( + bits((&left, &right)), + bits((&twin_left, &twin_right)), + "the never-swapped twin" + ); + + let record = IndexedRamp::new( + gated(OPEN, [false; 2]), + gated(MOVED, [false; 2]), + RECORD_RAMP, + ); + let at = record.coefficients_at(BEFORE as u32); + let jump = IndexedRamp::new(at, gated(MOVED, [false, true]), MUTE); + let (mut want_left, mut want_right) = input(AFTER); + route_mix_column_ramps_block::( + &mut want_left, + &mut want_right, + &[record, jump], + [BEFORE as u32, 0], + ); + assert_eq!(bits((&left, &right)), bits((&want_left, &want_right))); + let ramps = successor.route.ramps; + assert_eq!( + ramps.ramps[0].length, RECORD_RAMP, + "the open column keeps R" + ); + assert_eq!( + ramps.positions[0], + (BEFORE + AFTER) as u32, + "and its position" + ); + assert_eq!(ramps.ramps[1].length, MUTE); + } + + /// MAJOR-3 gate, following successor (record wins). The join's record for the swap + /// block (a move over 256) and the source's left curve, which turns on exactly at the + /// swap, meet at the successor's first block. As in the uninterrupted plan, the + /// record composes with the curve the route had heard (open) and moves both columns + /// over 256, then the curve's jump retargets the left column over the mute length: + /// the swap block equals the never-swapped twin's, with column lengths 48 and 256. + /// + /// Test value: red if the carried block places the curve before the record composes + /// (the record then aims at the curve's target, and the curve's jump takes the + /// record's 256 instead of 48: the probe of MAJOR-3). + #[test] + fn a_carried_record_composes_with_the_heard_curve_then_the_curve_jumps() { + let on_at_swap = steps(&[0, FIRST + BEFORE as u64], &[0.0, 1.0], 1 << 20); + let lanes = [Some(&on_at_swap[..]), None]; + let mut twin = Fixture::new(lanes, 0, OPEN); + let (mut left, mut right) = input(BEFORE); + assert!(twin.block(FIRST, None, &mut left, &mut right)); + twin.write(MOVED, RECORD_RAMP); + let (mut twin_left, mut twin_right) = input(AFTER); + assert!(twin.block(FIRST + BEFORE as u64, None, &mut twin_left, &mut twin_right)); + + let mut predecessor = Fixture::new(lanes, 0, OPEN); + let (mut left, mut right) = input(BEFORE); + assert!(predecessor.block(FIRST, None, &mut left, &mut right)); + let mut successor = Fixture::new(lanes, 0, OPEN); + carry_route( + CarriedRoute::Seven(&mut successor.route), + CarriedRoute::Seven(&mut predecessor.route), + None, + ); + successor.write(MOVED, RECORD_RAMP); + let (mut left, mut right) = input(AFTER); + assert!(successor.block(FIRST + BEFORE as u64, None, &mut left, &mut right)); + assert_eq!(bits((&left, &right)), bits((&twin_left, &twin_right))); + + let at = gated(OPEN, [false; 2]); + let curve = IndexedRamp::new(at, gated(MOVED, [true, false]), MUTE); + let record = IndexedRamp::new(at, gated(MOVED, [false; 2]), RECORD_RAMP); + let (mut want_left, mut want_right) = input(AFTER); + route_mix_column_ramps_block::( + &mut want_left, + &mut want_right, + &[curve, record], + [0, 0], + ); + assert_eq!(bits((&left, &right)), bits((&want_left, &want_right))); + let ramps = successor.route.ramps; + assert_eq!( + [ramps.ramps[0].length, ramps.ramps[1].length], + [MUTE, RECORD_RAMP] + ); + } + + /// MAJOR-3 gate, six-word successor (record wins, the curve removed). A following + /// route whose left column the curve holds muted is carried into a six-word + /// successor, and the join writes the successor's record (a move to `MOVED` over + /// 256) for its first block. In the uninterrupted order the record composes with the + /// heard curve (left column still zero) and then the curve's removal reopens the + /// route to the record's own target over the mute length; a six-word route holds one + /// ramp, so that reopening restarts it: `IndexedRamp::new(at, record, 48)`. + /// + /// Test value: red if the record governs alone (the six-word successor ramps to the + /// record over 256 and the curve's removal never reopens the left column over the + /// mute length; mutation M1 of the brief), or if the record skips the heard curve. + #[test] + fn a_six_word_successors_record_composes_with_the_heard_curve_then_reopens() { + let on = steps(&[0], &[1.0], 1 << 20); + let mut predecessor = Fixture::new([Some(&on), None], 0, OPEN); + let (mut left, mut right) = input(BEFORE); + assert!(predecessor.block(FIRST, None, &mut left, &mut right)); + let at = gated(OPEN, [true, false]); + assert_eq!(predecessor.route.ramps, ColumnRamps::settled(at)); + + let mut successor = Six::new(gated(OPEN, [false; 2]), false); + let mut check = None; + assert!(carry_route( + CarriedRoute::Six(&mut successor.route), + CarriedRoute::Seven(&mut predecessor.route), + Some(&mut check), + )); + let target = gated(MOVED, [false; 2]); + successor.write(target, RECORD_RAMP); + finish_six_word_carry( + &mut successor.route, + check.expect("a comparison is left"), + successor.gate.snapshot(), + Some(LENGTHS), + ); + let (mut left, mut right) = input(AFTER); + assert!(successor.block(&mut left, &mut right)); + let reopened = IndexedRamp::new(at, target, MUTE); + let (mut want_left, mut want_right) = input(AFTER); + route_mix_ramp_block::(&mut want_left, &mut want_right, &reopened, 0); + assert_eq!(bits((&left, &right)), bits((&want_left, &want_right))); + assert_eq!(successor.route.ramp, reopened); + } + } } } diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index b3107b972..293ec3b7e 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -3422,8 +3422,15 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( let strip_delay_carry_bytes = graph::strip_delay_carry_retained_bytes(strip_delay_carry.moves.len()) .ok_or_else(|| resource("host.resource.arithmetic"))?; - let route_carry_bytes = graph::route_carry_retained_bytes(route_carry.moves.len()) - .ok_or_else(|| resource("host.resource.arithmetic"))?; + let route_carry_bytes = graph::route_carry_retained_bytes( + route_carry.moves.len(), + route_carry + .moves + .iter() + .filter(|step| step.predecessor_follows) + .count(), + ) + .ok_or_else(|| resource("host.resource.arithmetic"))?; // The effect moves' table and payload scratch are charged at their exact size, against the // same cap as the rest of the plan, and the scratch is one allocation (#1279 D3). // The carried slots of the successor lanes the moves name are allocated with the table, and @@ -5543,16 +5550,19 @@ fn resolve_route_carry<'a>( let row = |rows: &[graph::GraphLiveRoute], id: &str| { rows.binary_search_by(|row| (*row.route_id).cmp(id)) .ok() - .map(|index| rows[index].unit) + .map(|index| (rows[index].unit, rows[index].following)) }; let mut carry = RouteCarry::default(); for join in joins { - let (Some(before), Some(after)) = (row(predecessor, join.id), row(own, join.id)) else { + let (Some((before, follows)), Some((after, _))) = + (row(predecessor, join.id), row(own, join.id)) + else { continue; }; carry.moves.push(graph::GraphRouteMove { successor: after, predecessor: before, + predecessor_follows: follows, }); if let Some(record) = join.retarget { carry.retargets.push((join.id, record)); @@ -6297,6 +6307,7 @@ mod tests { let live = |route: &str, unit| graph::GraphLiveRoute { route_id: route.into(), unit, + following: false, }; let before = [live("a-main", 3), live("b-main", 5)]; let own = [live("a-main", 4), live("b-main", 2), live("c-main", 9)]; @@ -6330,10 +6341,12 @@ mod tests { graph::GraphRouteMove { successor: 4, predecessor: 3, + predecessor_follows: false, }, graph::GraphRouteMove { successor: 2, predecessor: 5, + predecessor_follows: false, }, ] ); diff --git a/crates/lane/src/kernels.rs b/crates/lane/src/kernels.rs index 367510b44..d116b9b6b 100644 --- a/crates/lane/src/kernels.rs +++ b/crates/lane/src/kernels.rs @@ -1788,3 +1788,210 @@ fn route_mix_ramp_tail(left: &mut [f32], right: &mut [f32], ramp: &IndexedRamp, *right = ::fma(rr, old_right, ::mul(rl, old_left)); } } + +/// The 2x2 route mix of [`route_mix_ramp_block`] with one [`IndexedRamp`] per source column +/// (#1652, root ruling R18-5): column 0 is the source's left input, coefficients `ll` and `rl` +/// (indices 0 and 2), read from `ramps[0]` at `positions[0]`; column 1 is the source's right +/// input, `lr` and `rr` (indices 1 and 3), read from `ramps[1]` at `positions[1]`. A ramp's +/// entries of the other column are never read. +/// +/// Frame `f` of the block takes ramp index `k_c = positions[c] + f + 1` in column `c`, and each +/// coefficient follows [`IndexedRamp`]'s law exactly as [`route_mix_ramp_block`] computes it: +/// `fma(k, step, start)` while `k < length`, `target` assigned from `k = length`. The frame is +/// then mixed in [`mix2x2_block`]'s frozen order. The block splits by frame count, decided before +/// any loop: +/// +/// 1. the frames where both columns ramp; +/// 2. the frames where only the column with the longer remaining ramp still ramps (the other +/// column's coefficients are its target, assigned); +/// 3. the frames where both have settled: [`mix2x2_block`] with the two targets, unchanged. +/// +/// Parts 1 and 2 run their whole vectors at `L` (the frame-index vectors and one `fma` per +/// ramping coefficient, as [`route_mix_ramp_block`]) and their last frames in one outlined, +/// non-generic `f32` function; part 3 is [`route_mix_ramp_block`]'s settled part. Every +/// coefficient is a pure function of its column's index, so the result does not depend on the +/// width, and with `ramps[0] == ramps[1]` and equal positions it is [`route_mix_ramp_block`] of +/// that ramp bit for bit (the same parts, the same split, the same arithmetic). A route whose two +/// columns have settled therefore mixes exactly what a prepared route of those targets mixes. +/// +/// The caller advances each position by the block's length and saturates it at its own ramp's +/// length. `#[inline(never)]` keeps every instantiation a named function. +#[inline(never)] +pub fn route_mix_column_ramps_block( + left: &mut [f32], + right: &mut [f32], + ramps: &[IndexedRamp; 2], + positions: [u32; 2], +) { + debug_assert_eq!(left.len(), right.len()); + debug_assert!( + ramps + .iter() + .all(|ramp| ramp.length <= INDEXED_RAMP_LENGTH_MAXIMUM) + ); + // Both planes cut to the shorter one, as in `route_mix_ramp_block`: no index below can fail. + let count = core::cmp::min(left.len(), right.len()); + let (left, right) = (&mut left[..count], &mut right[..count]); + // Column `c` ramps for its first `length - position - 1` frames. + let ramping = |c: usize| { + let frames = ramps[c] + .length + .saturating_sub(positions[c]) + .saturating_sub(1); + core::cmp::min(count, frames as usize) + }; + let ramping = [ramping(0), ramping(1)]; + let both = core::cmp::min(ramping[0], ramping[1]); + let either = core::cmp::max(ramping[0], ramping[1]); + let (left_both, left_rest) = left.split_at_mut(both); + let (right_both, right_rest) = right.split_at_mut(both); + let (left_one, left_settled) = left_rest.split_at_mut(either - both); + let (right_one, right_settled) = right_rest.split_at_mut(either - both); + column_ramps_part::(left_both, right_both, ramps, positions, 0); + if ramping[0] > ramping[1] { + column_ramps_part::(left_one, right_one, ramps, positions, both); + } else { + column_ramps_part::(left_one, right_one, ramps, positions, both); + } + let target = [ + ramps[0].target[0], + ramps[1].target[1], + ramps[0].target[2], + ramps[1].target[3], + ]; + let settled = left_settled.len(); + let settled_vectored = settled - settled % L::WIDTH; + let (left_vectors, left_tail) = left_settled.split_at_mut(settled_vectored); + let (right_vectors, right_tail) = right_settled.split_at_mut(settled_vectored); + mix2x2_block::(left_vectors, right_vectors, target); + if !left_tail.is_empty() { + route_mix_settled_tail(left_tail, right_tail, target); + } +} + +/// One part of [`route_mix_column_ramps_block`]: frames `first..first + left.len()` of the block, +/// column 0 ramping when `RAMP0` and column 1 when `RAMP1` (otherwise at its target, assigned). +/// The whole vectors run at `L`; the last frames go to [`route_mix_column_ramps_tail`]. +#[inline(always)] +fn column_ramps_part( + left: &mut [f32], + right: &mut [f32], + ramps: &[IndexedRamp; 2], + positions: [u32; 2], + first: usize, +) { + let count = core::cmp::min(left.len(), right.len()); + if count == 0 { + return; + } + let (left, right) = (&mut left[..count], &mut right[..count]); + let vectored = count - count % L::WIDTH; + let (left_vectors, left_tail) = left.split_at_mut(vectored); + let (right_vectors, right_tail) = right.split_at_mut(vectored); + // A ramping column's frames have `k < length <= 2^22`, so `position + first` is exact. + let first = u32::try_from(first).unwrap_or(u32::MAX); + let base = positions.map(|position| position.saturating_add(first)); + let (zero, one) = (&ramps[0], &ramps[1]); + let step = [ + L::splat(zero.step[0]), + L::splat(one.step[1]), + L::splat(zero.step[2]), + L::splat(one.step[3]), + ]; + let start = [ + L::splat(zero.start[0]), + L::splat(one.start[1]), + L::splat(zero.start[2]), + L::splat(one.start[3]), + ]; + let target = [ + L::splat(zero.target[0]), + L::splat(one.target[1]), + L::splat(zero.target[2]), + L::splat(one.target[3]), + ]; + let advance = L::splat(L::WIDTH as f32); + let offsets = L::load(&FRAME_INDEX_OFFSETS[..L::WIDTH]); + let mut index = [ + L::splat(base[0] as f32).add(offsets), + L::splat(base[1] as f32).add(offsets), + ]; + for (left, right) in left_vectors + .chunks_exact_mut(L::WIDTH) + .zip(right_vectors.chunks_exact_mut(L::WIDTH)) + { + let (ll, rl) = if RAMP0 { + ( + index[0].fma(step[0], start[0]), + index[0].fma(step[2], start[2]), + ) + } else { + (target[0], target[2]) + }; + let (lr, rr) = if RAMP1 { + ( + index[1].fma(step[1], start[1]), + index[1].fma(step[3], start[3]), + ) + } else { + (target[1], target[3]) + }; + let old_left = L::load(left); + let old_right = L::load(right); + lr.fma(old_right, ll.mul(old_left)).store(left); + rr.fma(old_right, rl.mul(old_left)).store(right); + if RAMP0 { + index[0] = index[0].add(advance); + } + if RAMP1 { + index[1] = index[1].add(advance); + } + } + if !left_tail.is_empty() { + let vectored = u32::try_from(vectored).unwrap_or(u32::MAX); + route_mix_column_ramps_tail( + left_tail, + right_tail, + ramps, + base.map(|base| base.saturating_add(vectored).saturating_add(1)), + [RAMP0, RAMP1], + ); + } +} + +/// The frames of a [`route_mix_column_ramps_block`] part that do not fill a vector: the same +/// arithmetic at `f32`, column `c`'s frame `f` at ramp index `first[c] + f` when `ramping[c]`, +/// at its target otherwise. Non-generic and never inlined, so no vector instantiation carries a +/// scalar ramp body (the #926 lesson, as [`route_mix_ramp_block`]'s tails). +#[inline(never)] +fn route_mix_column_ramps_tail( + left: &mut [f32], + right: &mut [f32], + ramps: &[IndexedRamp; 2], + first: [u32; 2], + ramping: [bool; 2], +) { + let (zero, one) = (&ramps[0], &ramps[1]); + let coefficient = |ramp: &IndexedRamp, ramps: bool, at: usize, k: u32| { + if ramps { + // Exact: `k < length <= 2^22`. + ::fma(k as f32, ramp.step[at], ramp.start[at]) + } else { + ramp.target[at] + } + }; + for ((left, right), frame) in left.iter_mut().zip(right.iter_mut()).zip(0_u32..) { + let k = [ + first[0].saturating_add(frame), + first[1].saturating_add(frame), + ]; + let ll = coefficient(zero, ramping[0], 0, k[0]); + let rl = coefficient(zero, ramping[0], 2, k[0]); + let lr = coefficient(one, ramping[1], 1, k[1]); + let rr = coefficient(one, ramping[1], 3, k[1]); + let old_left = *left; + let old_right = *right; + *left = ::fma(lr, old_right, ::mul(ll, old_left)); + *right = ::fma(rr, old_right, ::mul(rl, old_left)); + } +} diff --git a/crates/lane/tests/route_ramp.rs b/crates/lane/tests/route_ramp.rs index e2b8ef934..1ac242f8e 100644 --- a/crates/lane/tests/route_ramp.rs +++ b/crates/lane/tests/route_ramp.rs @@ -9,7 +9,10 @@ #![allow(missing_docs)] use dsp_reference::class_a; -use lane::kernels::{INDEXED_RAMP_LENGTH_MAXIMUM, IndexedRamp, mix2x2_block, route_mix_ramp_block}; +use lane::kernels::{ + INDEXED_RAMP_LENGTH_MAXIMUM, IndexedRamp, mix2x2_block, route_mix_column_ramps_block, + route_mix_ramp_block, +}; use lane::{CanonicalFpEnv, Lane}; /// The block lengths of every gate: whole vectors at every width, a tail, and a tail only. @@ -357,3 +360,202 @@ fn a_settled_ramp_is_the_static_mix() { let _canonical = CanonicalFpEnv::enter(); lane::each_lane!(|L| settled_width::()); } + +/// The oracle of `route_mix_column_ramps_block`: column 0 (`ll`, `rl`) by `zero` at +/// `k = positions[0] + f + 1`, column 1 (`lr`, `rr`) by `one` at `k = positions[1] + f + 1`, then +/// the 2x2 mix in `mix2x2_block`'s order. +fn render_columns(laws: [&Law; 2], left: &mut [f32], right: &mut [f32], positions: [u32; 2]) { + for (frame, (left, right)) in left.iter_mut().zip(right.iter_mut()).enumerate() { + let frame = frame as u32; + let zero = laws[0].at(positions[0].saturating_add(frame).saturating_add(1)); + let one = laws[1].at(positions[1].saturating_add(frame).saturating_add(1)); + let [ll, _, rl, _] = zero; + let [_, lr, _, rr] = one; + let (l, r) = (*left, *right); + let (left_product, right_product) = (ll * l, rl * l); + *left = lr * r + left_product; + *right = rr * r + right_product; + } +} + +fn shared_width() { + let mut rng = Rng(0x0007_a110_fc01_u64); + for case in 0..96 { + let start: [f32; 4] = core::array::from_fn(|_| rng.coefficient()); + let target: [f32; 4] = core::array::from_fn(|_| rng.coefficient()); + let length = match case % 4 { + 0 => 0, + 1 => 1 + rng.next_u32() % 64, + 2 => 1 + rng.next_u32() % 6000, + _ => INDEXED_RAMP_LENGTH_MAXIMUM - rng.next_u32() % 300, + }; + let ramp = IndexedRamp::new(start, target, length); + for frames in BLOCKS { + let back = rng.next_u32() % 300; + for position in [0, 1, length.saturating_sub(back), length] { + let (left, right) = planes(&mut rng, frames, case); + let (mut got_left, mut got_right) = (left.clone(), right.clone()); + let (mut want_left, mut want_right) = (left, right); + route_mix_column_ramps_block::( + &mut got_left, + &mut got_right, + &[ramp; 2], + [position; 2], + ); + route_mix_ramp_block::(&mut want_left, &mut want_right, &ramp, position); + let label = format!( + "width {}, case {case}, length {length}, {frames} frames at {position}", + L::WIDTH + ); + assert_same( + &label, + "left", + &got_left, + &want_left, + "route_mix_ramp_block", + ); + assert_same( + &label, + "right", + &got_right, + &want_right, + "route_mix_ramp_block", + ); + } + } + // Two settled columns of different targets: the static mix of the two targets, which is + // what a prepared route (and the six-word kernel's settled ramp) of those targets mixes. + let other: [f32; 4] = core::array::from_fn(|_| rng.coefficient()); + let composite = [target[0], other[1], target[2], other[3]]; + let ramps = [ramp, IndexedRamp::new(start, other, length / 2)]; + for frames in BLOCKS { + let (left, right) = planes(&mut rng, frames, case + 1); + let (mut got_left, mut got_right) = (left.clone(), right.clone()); + let (mut want_left, mut want_right) = (left.clone(), right.clone()); + let (mut static_left, mut static_right) = (left, right); + route_mix_column_ramps_block::( + &mut got_left, + &mut got_right, + &ramps, + [length, length / 2], + ); + route_mix_ramp_block::( + &mut want_left, + &mut want_right, + &IndexedRamp::settled(composite), + 0, + ); + mix2x2_block::(&mut static_left, &mut static_right, composite); + let label = format!("width {}, case {case}, settled, {frames} frames", L::WIDTH); + assert_same( + &label, + "left", + &got_left, + &want_left, + "route_mix_ramp_block", + ); + assert_same( + &label, + "right", + &got_right, + &want_right, + "route_mix_ramp_block", + ); + assert_same(&label, "left", &got_left, &static_left, "mix2x2_block"); + assert_same(&label, "right", &got_right, &static_right, "mix2x2_block"); + } + } +} + +/// #1652 R18-5 gate: with one ramp in both columns at one position, the column-ramp kernel is the +/// six-word kernel bit for bit (lengths 0 to `2^22`, from the start, mid-ramp near the snap and +/// settled); with two settled columns it is `mix2x2_block` of the two targets, the static mix a +/// prepared route of those targets runs. At every width. +/// +/// Test value: red if the column kernel splits its parts differently from the six-word kernel +/// (a frame computed instead of assigned at the snap), reads a coefficient from the wrong +/// column's ramp, or settles by its own law instead of `mix2x2_block`'s. +#[test] +fn equal_column_ramps_are_the_six_word_kernel_and_settled_ones_the_static_mix() { + let _canonical = CanonicalFpEnv::enter(); + lane::each_lane!(|L| shared_width::()); +} + +fn columns_width() { + let mut rng = Rng(0x0c01_0000_5eed_u64); + let cases = [ + // (length 0, length 1, position 0, position 1): the left column ends first, the right + // first, one settled throughout, both ending in one vector, and a long and a short ramp. + (48, 78, 0, 30), + (78, 48, 30, 0), + (0, 4800, 0, 17), + (5, 6, 0, 0), + (37, 4800, 2, 4795), + ( + INDEXED_RAMP_LENGTH_MAXIMUM, + 13, + INDEXED_RAMP_LENGTH_MAXIMUM - 129, + 0, + ), + ]; + for (case, (length_zero, length_one, position_zero, position_one)) in + cases.into_iter().enumerate() + { + let starts: [[f32; 4]; 2] = + core::array::from_fn(|_| core::array::from_fn(|_| rng.coefficient())); + let targets: [[f32; 4]; 2] = + core::array::from_fn(|_| core::array::from_fn(|_| rng.coefficient())); + let ramps = [ + IndexedRamp::new(starts[0], targets[0], length_zero), + IndexedRamp::new(starts[1], targets[1], length_one), + ]; + let laws = [ + Law::new(starts[0], targets[0], length_zero), + Law::new(starts[1], targets[1], length_one), + ]; + for frames in BLOCKS { + let mut positions = [position_zero, position_one]; + for block in 0..6 { + let (left, right) = planes(&mut rng, frames, block); + let (mut got_left, mut got_right) = (left.clone(), right.clone()); + let (mut want_left, mut want_right) = (left, right); + route_mix_column_ramps_block::(&mut got_left, &mut got_right, &ramps, positions); + render_columns( + [&laws[0], &laws[1]], + &mut want_left, + &mut want_right, + positions, + ); + let label = format!( + "width {}, case {case}, {frames} frames, block {block} at {positions:?}", + L::WIDTH + ); + assert_same(&label, "left", &got_left, &want_left, "the column oracle"); + assert_same( + &label, + "right", + &got_right, + &want_right, + "the column oracle", + ); + for (position, ramp) in positions.iter_mut().zip(&ramps) { + *position = ramp.length.min(position.saturating_add(frames as u32)); + } + } + } + } +} + +/// #1652 R18-5 gate: two column ramps of different lengths and positions follow each column's +/// own indexed law, equal to an independent scalar oracle per column, at every width: the left +/// column ending before the right and after it, one column settled throughout, both ending in one +/// vector, and a ramp at the `2^22` bound beside a short one. +/// +/// Test value: red if a column takes the other column's index or length (the shared-ramp law), +/// if the one-ramping part assigns the ramping column's target, or if a part's tail starts at the +/// wrong frame index. +#[test] +fn each_column_follows_its_own_ramp() { + let _canonical = CanonicalFpEnv::enter(); + lane::each_lane!(|L| columns_width::()); +} From 6f758cb7591b7f37511b3e662c0f29133114c3e6 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 08:31:31 +0000 Subject: [PATCH 12/23] Attach a following route to every send of an automated mute (#1652c) Every plan gives a route into a submix whose source strip has a mute entry a seven-word lane, sourced from the strip's mute cells, the tap's floored arrival and the session mute length rule, with flags seeded from the effective strip mute. The swap join writes a seven-word record through the follow producer only when a word changes (never on a change of kind alone). Until #1653 lands in this batch, a live edit of such a send rebuilds. Gates: no leak on both hosts and host-core at every width; the send gates on the strip's timeline sample with the same length; a rebuild adding or removing the entry keeps or reopens the send allocation-free; the charge covers the attach; the realtime binary. pcm_digest unchanged; realtime policy 167. Co-Authored-By: Claude Opus 5.5 --- ...mated-mute-on-following-sends-in-render.md | 84 ++ crates/builtins-compiler/src/lib.rs | 21 + crates/capi/src/runtime/live_tests.rs | 172 ++++ .../tests/follow_automation_realtime.rs | 347 +++++++ crates/graph/src/lib.rs | 72 +- crates/host-core/src/lib.rs | 8 +- crates/host-core/src/live_delta.rs | 17 +- crates/host-core/src/prepare.rs | 310 ++++++- crates/host-core/tests/effect_observation.rs | 1 + crates/host-core/tests/live_routes.rs | 1 + crates/host-core/tests/route_mute.rs | 845 ++++++++++++++++++ hosts/host-web/tests/control_parity.rs | 104 +++ 12 files changed, 1944 insertions(+), 38 deletions(-) create mode 100644 crates/control-plane/tests/follow_automation_realtime.rs diff --git a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md index 9ccf712bc..790173957 100644 --- a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md +++ b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md @@ -533,3 +533,87 @@ later tranche (after #1653). (b) red; carried comparison as a step -> six->seven (b) red; six-word comparison missing -> seven->six red. Six -> six carry keeps today's code path; `host-core` `route_mute` (9) and `live_routes` (13) pass. + +## Decision record: #1652c (batch 18) + +Tranche R3: the host-core attach, the seven-word record builder and the swap join. No render code +(`crates/graph/src/runtime.rs` untouched). + +- **Attach (`crates/host-core/src/prepare.rs`).** `follows_automated_mute(model, route)` is the + one predicate: a route into a submix whose source strip has a mute entry + (`controls::mute_automation`, the entries the builtins plan compiles mute cells from). Before + lowering, preparation reads `PreparedBuiltinsSession::strip_mute_cells` for each such route and + seeds its flags (route `mute`, `follows_mute`, `terms = controls::effective`'s source-lane mute + under the plan's overlay, `automated == cells`). After compile it builds + `GraphFollowRouteSource::new(program, cells, floored_input_arrival(input_arrivals, route node), + follow_mute_jump, flags)`. `follow_mute_jump` is a plain fn: + `LiveRamps::from_lengths(f, m, p).for_row(LiveRampRow::Mute)`, in its own realtime-policy + region. The attach gives a following lane on every plan, whatever the live options (six-word + lanes still only with live controls and `lanes.routes`), and leaves those routes out of + `route_controls`. A plan with no following route and no live lane calls no attach and binds what + it bound before. Every preparation runs inside #1709's `CanonicalFpEnv` (R17-7); no new guard. + A count mismatch between the predicate and the attached lanes is `host.route_control.attach` + (unreachable for a validated session). +- **Graph (`crates/graph/src/lib.rs`, small).** `PreparedGraphPlan::attach_route_controls_following( + superseded, six_word, following)` returns both producer lists; `attach_route_controls` calls it + with `(true, |_| None)`, so six-word bits and bytes are unchanged. The dead-code expect on + `follow_route_control_pair` is gone. Mechanical wrapper in + `crates/builtins-compiler/src/lib.rs`: `attach_route_live_controls_following`. +- **Handles.** `HostLiveControlHandles::follow_route_controls: + Vec` (canonical route-ID order) and + `follow_route_producer_position` (re-exported). Charge: `route_control_resources(&six, + &following)`. Mechanical: two struct literals in `crates/host-core/tests/{live_routes, + effect_observation}.rs` gain the field. control-plane still drops the follow producers (`..`); + #1653 keeps them. +- **Record builder.** `follow_route_record(route, terms, automated, length)` (open transform from + `graph_compiler::route_values`) and `follow_route_record_between(ramps, before, after, terms, + automated, gates, gate_length, edit)`, next to `route_record_between`. A record only when the + transform bits, `mute`, `follows_mute` or `terms` change; `automated` is the next plan's and is + never compared, so a change of kind alone writes nothing (coordinator obligation 1). Length: + `gate_length` when the effective gate changes, else `SendFollowsMute` when only `follows_mute` + changes, else the longest changed `SendGain`/`SendMatrix`, else `SendMute`. `SendRecord { + Folded, Following }` is the join's record kind. +- **Join.** `join_routes` gives a successor following route the seven-word record (terms from + `EffectiveDiff::strip_mutes`), any other route today's. The carry loop writes `Folded` through + `route_controls` and `Following` through `follow_route_controls[...].write` under the carry's + one stamp (the revision the host publishes before the successor's first block). +- **No `host.control.order` / internal fault for a following route.** Until #1653, the classifier + (`live_delta.rs`, `route_records`) answers `RebuildOnly(RuledLiveNotBuilt { issue: 1653 })` for + any route lowering of a following route (a send edit, or a strip mute that moves its gate), so no + commit writes a six-word record to a route with no six-word cell; the rebuild's join writes the + seven-word record. #1653 replaces this branch with its live record. +- **Departure.** Gate 2 uses `L = 486` (the true-peak limiter at 48 kHz): no native effect declares + 64. Gate 2 measures at the output (after PDC); "the send ramp starts `L` render samples before + the strip's" follows from the bus path's `L`-sample compensation, which the test does not probe + at the op. +- **Gates (all pass unless marked).** + - Gate 1: `crates/host-core/tests/route_mute.rs` + `a_following_send_leaks_nothing_of_a_stored_mute` (Simd8 on AVX2, Simd4, Scalar; no live + control, live controls, between render calls; quanta 128 and 100; signed feed; windows from the + next block boundary after each ramp's end); `crates/capi/src/runtime/live_tests.rs` + `a_following_send_leaks_nothing_of_a_stored_mute`; `hosts/host-web/tests/control_parity.rs` + `a_following_send_leaks_nothing_of_a_stored_mute_on_both_hosts` (both hosts' bits equal per + block, and each against its static twins). + - Gate 2: `the_send_gates_on_the_timeline_sample_the_strip_mutes` (input and post_fader taps, + quanta 128 and 100, every width): mute and unmute start on the strip's output sample + (`step + L`), same length (480) for the input tap. + - Gate 4(a): `a_rebuild_that_adds_a_mute_entry_keeps_the_sends_ramp` (live predecessor + mid-ramp, six -> seven: bit for bit the never-swapped twin until the step, no join write, every + block after 0 allocation- and free-free by thread counters and the render audit). + - Gate 4(b): `a_rebuild_that_removes_the_mute_entry_reopens_the_send` (seven -> six: silent + before the swap, ramps in `[768, 768 + M)`, fresh-plan bits from the next boundary, quiet). + - Combined (obligation 3): `a_send_edit_and_a_mute_entry_in_one_rebuild_adopt_over_one_length` + is **red and `#[ignore]`d**: the send reaches `+0.0` at 1007 (768 + 239, the join record's + `SendGain` length, not `M`), which the graph fold (U2a) addresses; and the strip lane does not + mute at all at the swap (the carried strip lane does not adopt a new mute curve: #1651 T4). + - Charge (obligation 4): `the_following_route_charge_covers_its_attach_and_bind`: charged 922, + retained 683 bytes (x86-64), one route, a seven-word cell. + - Rebuild gate: `crates/capi/src/runtime/live_tests.rs` `an_edit_of_a_following_route_rebuilds`. + - Gate 5: `crates/control-plane/tests/follow_automation_realtime.rs` + `a_following_send_of_a_stored_mute_renders_allocation_free`. +- **Red checks (temporary mutations, reverted).** No lane attached (predicate false): gates 1 (all + three hosts), 2, 4(a), 4(b), 5, the charge and the rebuild gate red. Events at the fader's + arrival (`a(tap).max(486)`): gate 2 red (send at 1972). Carry drops the ramp (no join for a + following route): 4(a) red. A record on a change of kind (prior with `automated = [false; 2]`): + 4(a) red. Classifier gate removed: the rebuild gate red (result 255, internal fault). +- **Realtime.** `realtime_policy`: 167 marked regions (166 before; `follow_mute_jump`). diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 4a50dfb80..ede126e67 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -6586,6 +6586,27 @@ impl PreparedBuiltinsGraphArtifact { self.graph.attach_route_controls(superseded) } + /// [`Self::attach_route_live_controls`] with following routes (#1652c): delegates to + /// [`PreparedGraphPlan::attach_route_controls_following`], which gives every route into a + /// submix that `following` names a seven-word lane, and the others a six-word lane when + /// `six_word` is set. Returns the six-word and the seven-word producers, each in canonical + /// route-ID order. + pub fn attach_route_live_controls_following( + &mut self, + superseded: &LiveValuesSuperseded, + six_word: bool, + following: impl FnMut(&graph::StableGraphId) -> Option, + ) -> Result< + ( + Vec, + Vec, + ), + graph::GraphRouteControlError, + > { + self.graph + .attach_route_controls_following(superseded, six_word, following) + } + /// Ordinary external nodes required in addition to compiler-owned builtin processors. pub fn external_binding_nodes(&self) -> impl Iterator { let builtin_nodes: BTreeSet<_> = self diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 96cba400d..d5b2d790d 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -10932,3 +10932,175 @@ fn a_removed_fader_ride_releases_to_the_static_fader() { ); } } + +/// #1652 gate 1: track `eq0` sends `pre_fader` (its right lane, `rr` only) with `follows_mute` +/// into bus `b`, and its direct route takes only its left lane to the output, so the output's +/// left plane is the strip and its right plane the send. With `entry`, `eq0` has a mute entry on +/// `both` with steps 0, 1, 0 at 1,000 and 2,500; with `muted`, `eq0` is muted statically. +fn follow_document(entry: bool, muted: bool) -> String { + use session::{ + Automation, AutomationOffset, AutomationSegment, AutomationShape, ChannelMatrix, + ControlAddress, Owner, ParameterChannel, ParameterUnit, Section, SendTap, + }; + let id = |text: &str| StableId::parse(text).expect("stable ID"); + let mut model = stateless_session(48_000); + model.tracks.truncate(1); + model.routes.truncate(1); + model.sources[0].frames = SOURCE_FRAMES; + model.control_smoothing = Some(ControlSmoothing { + mute_ms: 10.0, + fader_ms: 5.0, + pan_ms: 5.0, + }); + let track = model.tracks[0].id.clone(); + ( + model.tracks[0].fader.left_mute, + model.tracks[0].fader.right_mute, + ) = (muted, muted); + let matrix = |ll: f32, rr: f32| ChannelMatrix { + ll, + lr: 0.0, + rl: 0.0, + rr, + }; + model.routes[0].channel_matrix = matrix(1.0, 0.0); + let mut send = model.routes[0].clone(); + send.id = id("eq0-b"); + send.source = RouteSource::Track { + track_id: track.clone(), + tap: SendTap::PreFader, + }; + send.destination = RouteDestination::SubmixInput { submix_id: id("b") }; + send.channel_matrix = matrix(0.0, 1.0); + send.follows_mute = true; + let mut out = model.routes[0].clone(); + out.id = id("b-main"); + out.source = RouteSource::Submix { + submix_id: id("b"), + tap: SendTap::PostPan, + }; + out.channel_matrix = matrix(1.0, 1.0); + model.routes.push(send); + model.routes.push(out); + let console = model.console.clone(); + model.submixes.push(Submix::unity(id("b"), &console)); + if entry { + let step = |start: u64, end: u64, from: f32, to: f32| AutomationSegment { + shape: AutomationShape::Step, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: ParameterUnit::Linear, + }; + model.automation.push(Automation { + id: id("eq0-mute"), + target: ControlAddress { + owner: Owner::Strip(track), + section: Section::Builtins, + control: 6, + lane: ParameterChannel::Both, + }, + segments: vec![step(0, 1_000, 0.0, 1.0), step(1_000, 2_500, 1.0, 0.0)], + offset: AutomationOffset::default(), + }); + } + session::canonical_session_json(&model).expect("canonical follow session") +} + +/// #1652 gate 1 on the C ABI. From the first block boundary after the end of the mute ramp (1,000 +/// plus the 480-sample mute length) to the unmute at 2,500, the output is bit for bit the plan with +/// `eq0` statically muted, fed the same PCM (the right lane is negative, so a signed zero shows); +/// from the first block boundary after the end of the unmute ramp it is bit for bit the plan with +/// no mute. Not vacuous: the send's plane differs from the static mute's before the mute. +/// +/// Test value: red if the C ABI plan attaches no following lane for the send (its pre-fader audio +/// leaks while the strip is muted), or the send does not reopen after the unmute. +#[test] +fn a_following_send_leaks_nothing_of_a_stored_mute() { + const BLOCKS: usize = 32; + let run = |document: &str| -> Vec { + let mut rig = Rig::new(document); + let mut planes = [Vec::new(), Vec::new()]; + for _ in 0..BLOCKS { + let pcm = rig.step(); + let quantum = rig.quantum; + planes[0].extend_from_slice(&pcm[..quantum]); + planes[1].extend_from_slice(&pcm[quantum..]); + } + let [left, right] = planes; + left.into_iter().chain(right).collect() + }; + let quantum = 128_usize; + let frames = BLOCKS * quantum; + let curve = run(&follow_document(true, false)); + let muted = run(&follow_document(false, true)); + let open = run(&follow_document(false, false)); + let muted_window = (1_000 + 480_usize).div_ceil(quantum) * quantum..2_500; + let open_window = (2_500 + 480_usize).div_ceil(quantum) * quantum..frames; + for plane in 0..2 { + let at = |range: &core::ops::Range| { + plane * frames + range.start..plane * frames + range.end + }; + assert_eq!( + bits(&curve[at(&muted_window)]), + bits(&muted[at(&muted_window)]), + "plane {plane}: the statically muted plan's bits while the curve mutes" + ); + assert_eq!( + bits(&curve[at(&open_window)]), + bits(&open[at(&open_window)]), + "plane {plane}: the open plan's bits after the unmute" + ); + } + assert_ne!( + bits(&curve[frames..frames + 900]), + bits(&muted[frames..frames + 900]), + "the send carries audio before the mute" + ); +} + +/// #1652c: an edit that lowers a following route (a route into a submix whose source strip has a +/// mute entry) is a rebuild until #1653 gives the live commit the seven-word record: a send gain +/// edit on `eq0-b`, and a hand mute of `eq0` (which changes `eq0-b`'s follow gate). Each commits +/// with `rebuild`, the successor is adopted, and from the adoption on the rig renders what the +/// committed document renders fresh, seeked to the adoption sample. +/// +/// Test value: red if the live commit writes a six-word record to a route that has no six-word +/// cell (the commit refuses with an internal fault and the edit is lost), or if the rebuild's +/// successor does not render the committed values. +#[test] +fn an_edit_of_a_following_route_rebuilds() { + use protocol::control_core::{Lane, Owner, Section, row_address}; + let gain = SessionEdit::set_control( + row_address( + Owner::Route(StableId::parse("eq0-b").expect("route ID")), + Section::Own, + "gain_db", + Lane::Both, + ) + .expect("the send gain row"), + -9.0, + None, + ); + for edit in [gain, mute_edit("eq0", true)] { + let mut rig = Rig::new(&follow_document(true, false)); + for _ in 0..4 { + rig.step(); + } + let (adoption, live) = rebuild_and_adopt(&mut rig, std::slice::from_ref(&edit), 8); + let reference = seeked_reference(&rig.snapshot(), adoption, 8); + let settle = 4; + assert_eq!( + live[settle..] + .iter() + .map(|pcm| bits(pcm)) + .collect::>(), + reference[settle..] + .iter() + .map(|pcm| bits(pcm)) + .collect::>(), + "the successor renders the committed document once its ramps settle" + ); + } +} diff --git a/crates/control-plane/tests/follow_automation_realtime.rs b/crates/control-plane/tests/follow_automation_realtime.rs new file mode 100644 index 000000000..3a52f76df --- /dev/null +++ b/crates/control-plane/tests/follow_automation_realtime.rs @@ -0,0 +1,347 @@ +//! #1652 gate 5: a send that follows a stored mute renders allocation-free through the shared +//! commit. +//! +//! Gate 1's script runs through control-plane's shared commit (`compile_children` and +//! `SessionState::command`, the code the browser Worker and the C ABI both run) and host-core's +//! render session (`PlanState::render`): track `eq0` sends `pre_fader` with `follows_mute` into bus +//! `b`, and a mute entry on `both` steps `eq0` muted at 1,000 and open again at 2,500 (not grid +//! samples). A live move of `b`'s fader (a value the following route does not hold) commits in +//! the middle of the muted span. Every render call after the warm-up allocates and frees nothing, +//! measured with the audited allocator's thread-scoped counters: the blocks that cross the curve's +//! events, the follow's ramps and the bus move. + +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, ChannelMatrix, + ControlAddress, Owner, ParameterChannel, ParameterUnit, Route, RouteDestination, RouteSource, + Section, SendTap, StableId, Submix, +}; + +const SESSION: &str = include_str!("../../../fixtures/session/v1/parametric-eq-nine-track.json"); +const QUANTUM: usize = 128; +const SOURCE: &[u8] = b"fixture-source"; +/// The mute steps of gate 1's curve. +const DOWN: u64 = 1_000; +const UP: u64 = 2_500; + +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: 4, + maximum_vcas: 0, + maximum_vca_reach_pairs: u64::MAX, + maximum_replacement_peak_bytes: u64::MAX, + } +} + +fn id(text: &str) -> StableId { + StableId::parse(text).expect("stable ID") +} + +fn matrix(ll: f32, rr: f32) -> ChannelMatrix { + ChannelMatrix { + ll, + lr: 0.0, + rl: 0.0, + rr, + } +} + +/// The fixture's first track `eq0` alone, with no effect: `eq0-main` takes its left lane to the +/// output's left plane, `eq0-b` sends its right lane from `pre_fader` into bus `b` (and follows its +/// mute), and `b-main` takes the bus to the output, so the output's right plane is the send. +/// `eq0` has a mute entry on `both` with steps 0, 1, 0 at [`DOWN`] and [`UP`]. +fn document() -> (String, u64) { + let mut model = session::parse_session_json(SESSION).expect("fixture"); + model.automation.clear(); + model.console.pre_insert.clear(); + model.console.post_insert.clear(); + model.tracks.truncate(1); + let track = &mut model.tracks[0]; + track.console.clear(); + track.inserts.effects.clear(); + let route = |route: &str, source: RouteSource, destination, channel_matrix| Route { + id: id(route), + source, + destination, + channel_matrix, + gain_db: 0.0, + mute: false, + follows_mute: false, + }; + let mut send = route( + "eq0-b", + RouteSource::Track { + track_id: id("eq0"), + tap: SendTap::PreFader, + }, + RouteDestination::SubmixInput { submix_id: id("b") }, + matrix(0.0, 1.0), + ); + send.follows_mute = true; + let output = || RouteDestination::OutputInput { + output_id: id("main-out"), + }; + model.routes = vec![ + route( + "eq0-main", + RouteSource::Track { + track_id: id("eq0"), + tap: SendTap::PostPan, + }, + output(), + matrix(1.0, 0.0), + ), + send, + route( + "b-main", + RouteSource::Submix { + submix_id: id("b"), + tap: SendTap::PostPan, + }, + output(), + matrix(1.0, 1.0), + ), + ]; + model.submixes = vec![Submix::unity(id("b"), &model.console)]; + let step = |start: u64, end: u64, from: f32, to: f32| AutomationSegment { + shape: AutomationShape::Step, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: ParameterUnit::Linear, + }; + model.automation.push(Automation { + id: id("eq0-mute"), + target: ControlAddress { + owner: Owner::Strip(id("eq0")), + section: Section::Builtins, + control: 6, + lane: ParameterChannel::Both, + }, + segments: vec![step(0, DOWN, 0.0, 1.0), step(DOWN, UP, 1.0, 0.0)], + offset: AutomationOffset::default(), + }); + ( + session::canonical_session_json(&model).expect("canonical"), + model.revision, + ) +} + +struct Rig { + session: SessionState, + plan: PlanState, + revision: u64, + request: u64, + block: u64, + /// The last block's samples, left plane then right. + last: Vec, +} + +impl Rig { + 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 of a signed signal; the allocator events of the render call. + 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.495) + .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.last.copy_from_slice(&samples); + self.block += 1; + used + } + + /// Commit bus `b`'s fader at `db` on both lanes live, with the session's length. + fn move_bus(&mut self, db: f32) { + self.request += 1; + let edits = [SessionEdit::SetControl { + expected_effect: None, + address: ControlAddress { + owner: Owner::Strip(id("b")), + section: Section::Builtins, + control: 5, + lane: ParameterChannel::Both, + }, + value: db, + ramp_samples: None, + }]; + 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 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 move is refused: {payload:?}") + } + DecodedTypedResponseFrame::Success { .. } => panic!("another success payload"), + }; + assert_eq!(path, EditPath::Live, "the bus move is live"); + self.revision += 1; + while self + .session + .dequeue_event(EventLane::Reliable, 65_536) + .expect("an event") + .is_some() + {} + } +} + +/// Gate 5. Every render block after the warm-up allocates and frees nothing, across the curve's +/// mute at 1,000, a live bus move at 1,792 (inside the muted span) and the unmute at 2,500. Not +/// vacuous: the send (the output's right plane) is silent in a block of the muted span and +/// carries the source again after the unmute. +/// +/// Test value: red if the route op's evaluation of the curve, its cursors, its in-block retarget +/// or the cell read allocates or frees on render. +#[test] +fn a_following_send_of_a_stored_mute_renders_allocation_free() { + bench_support::alloc::assert_installed(); + let mut rig = Rig::new(); + for _ in 0..2 { + rig.block(); + } + let mut silent_while_muted = false; + let mut open_after = false; + while rig.block < 32 { + if rig.block == 14 { + rig.move_bus(-6.0); + } + let block = rig.block; + let used = rig.block(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "block {block} allocated or freed" + ); + let right = &rig.last[QUANTUM..]; + if block == 13 { + silent_while_muted = right.iter().all(|sample| *sample == 0.0); + } + if block == 30 { + open_after = right.iter().all(|sample| *sample != 0.0); + } + } + assert!(silent_while_muted, "the send follows the curve's mute"); + assert!(open_after, "the send reopens after the unmute"); +} diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 6a7175617..182a57984 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1484,7 +1484,6 @@ impl GraphFollowRouteControlProducer { /// One following route's binding and producer over one fresh seven-word cell (#1652 D1), reading /// `source` (#1652b). `activity_bytes` and `activity_largest` are the attach's route-activity charge, as for a /// six-word producer. -#[cfg_attr(not(test), expect(dead_code, reason = "called by #1652c route attach"))] pub(crate) fn follow_route_control_pair( route_id: &StableGraphId, superseded: &engine::realtime::LiveValuesSuperseded, @@ -2687,7 +2686,31 @@ impl PreparedGraphPlan { &mut self, superseded: &engine::realtime::LiveValuesSuperseded, ) -> Result, GraphRouteControlError> { - if self.route_controls.is_some() { + self.attach_route_controls_following(superseded, true, |_| None) + .map(|(producers, _)| producers) + } + + /// [`Self::attach_route_controls`] with following routes (#1652 D1, #1652c): every route into + /// a submix input for which `following` gives a source gets a seven-word lane reading it + /// ([`follow_route_control_pair`]) and no six-word lane; every other such route gets a + /// six-word lane when `six_word` is set and none otherwise. Both producer lists are in + /// canonical route-ID order. `following` is asked once per route into a submix, in that + /// order, so a route into an output never follows (decision 13 P11). + /// + /// Refused as [`Self::attach_route_controls`] is. + pub fn attach_route_controls_following( + &mut self, + superseded: &engine::realtime::LiveValuesSuperseded, + six_word: bool, + mut following: impl FnMut(&StableGraphId) -> Option, + ) -> Result< + ( + Vec, + Vec, + ), + GraphRouteControlError, + > { + if self.route_controls.is_some() || !self.follow_route_controls.is_empty() { return Err(GraphRouteControlError::AlreadyAttached); } // D1: the route's destination edge lands on a submix input (a strip `Input` stage, which @@ -2722,11 +2745,25 @@ impl PreparedGraphPlan { if live.iter().any(|node| required.contains(node)) { return Err(GraphRouteControlError::BoundRoute); } + // Which kind of lane each route gets: a following route its source, a six-word route + // nothing, a route with no lane is left out. + let mut kinds: Vec<(&StableGraphId, Option)> = + Vec::with_capacity(live.len()); + for node in live { + let GraphNodeId::Route { route_id } = node else { + continue; + }; + match following(route_id) { + Some(source) => kinds.push((route_id, Some(source))), + None if six_word => kinds.push((route_id, None)), + None => {} + } + } // D8: bind builds the route-activity table for a plan with a live route. The compile-time // estimate charged it already when some prepared gate silences; otherwise it is this // attach's to state. let (activity_bytes, activity_largest) = - if live.is_empty() || self.routes.iter().any(|route| route.gate.silences()) { + if kinds.is_empty() || self.routes.iter().any(|route| route.gate.silences()) { (0, 0) } else { let routes = u64::try_from(self.routes.len()).unwrap_or(u64::MAX); @@ -2736,15 +2773,29 @@ impl PreparedGraphPlan { runtime::route_activity_largest_bytes(routes, nodes).unwrap_or(u64::MAX), ) }; - let mut bindings = Vec::with_capacity(live.len()); - let mut producers = Vec::with_capacity(live.len()); - for node in live { - let GraphNodeId::Route { route_id } = node else { + let follow_count = kinds.iter().filter(|(_, source)| source.is_some()).count(); + let mut bindings = Vec::with_capacity(kinds.len() - follow_count); + let mut producers = Vec::with_capacity(kinds.len() - follow_count); + let mut follow_bindings = Vec::with_capacity(follow_count); + let mut follow_producers = Vec::with_capacity(follow_count); + for (route_id, source) in kinds { + if let Some(source) = source { + let (binding, producer) = follow_route_control_pair( + route_id, + superseded, + source, + activity_bytes, + activity_largest, + ); + follow_bindings.push(binding); + follow_producers.push(producer); continue; - }; + } let lane = std::sync::Arc::new(RouteLane::new()); bindings.push(GraphRouteControlBinding { - node: node.clone(), + node: GraphNodeId::Route { + route_id: route_id.clone(), + }, control: Box::new(RouteControlLane { lane: std::sync::Arc::clone(&lane), reader: engine::realtime::CellReader::new(), @@ -2761,7 +2812,8 @@ impl PreparedGraphPlan { }); } self.route_controls = Some(bindings); - Ok(producers) + self.follow_route_controls = follow_bindings; + Ok((producers, follow_producers)) } /// The lowered executable program, or `None` when the plan's schedule, levels and spec /// disagree (which bind-time structural validation rejects). diff --git a/crates/host-core/src/lib.rs b/crates/host-core/src/lib.rs index 7d1f45b15..5e7a29a25 100644 --- a/crates/host-core/src/lib.rs +++ b/crates/host-core/src/lib.rs @@ -150,10 +150,10 @@ pub use prepare::{ NO_OBSERVATION, PlanStateInventory, PreparedHost, SOURCE_STALL_TOLERANCE_MS, SourceInventoryRow, SuccessorBase, check_counts, compile_host_model, compile_host_session, count_effects, default_source_ring_frames, effect_producer_position, - jump_length_retained_bytes, pair_carried_meters, pair_carried_observations, parse_host_session, - prepare_host_runtime, prepare_host_runtime_between_render_calls, - prepare_host_runtime_successor, prepare_host_runtime_with_live_controls, - prepare_host_runtime_with_live_controls_and_spectrum, + follow_route_producer_position, jump_length_retained_bytes, pair_carried_meters, + pair_carried_observations, parse_host_session, prepare_host_runtime, + prepare_host_runtime_between_render_calls, prepare_host_runtime_successor, + prepare_host_runtime_with_live_controls, prepare_host_runtime_with_live_controls_and_spectrum, prepare_host_runtime_with_live_controls_and_spectrum_collection, prepare_host_runtime_with_live_controls_and_spectrum_collection_successor, prepare_host_runtime_with_live_controls_and_spectrum_successor, diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index 754a752d7..4d2ac7ab4 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -56,8 +56,9 @@ pub fn take_fast_classification_count() -> usize { // every build (a successor's carry-then-retarget, #1277 D5); the classifier calls them. use crate::prepare::{ 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, + StripEditRamps, effect_instance_records, follows_automated_mute, 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}; @@ -1467,11 +1468,21 @@ fn lower<'a>( /// # Errors /// /// [`LiveRebuild::Domain`] when either value set is outside the route domain, or a ramp is longer -/// than the cell's maximum. +/// than the cell's maximum. [`LiveRebuild::RebuildOnly`] for a route the plan gives the seven-word +/// cell of a following route (#1652 D1): its live record is #1653's, and until it lands a rebuild +/// prepares the successor's cell from the committed model and the send join writes its +/// seven-word record (#1652 D3), so no commit writes a six-word record to a route that has no +/// six-word cell. Automation is structure for a value transaction, so the predicate reads the +/// view's structure. fn route_records<'a>( ctx: &Context<'_>, target: &RouteTarget<'_, 'a>, ) -> Result>, LiveRebuild> { + if follows_automated_mute(ctx.next.structure(), target.after) { + return Err(LiveRebuild::RebuildOnly( + controls::RebuildReason::RuledLiveNotBuilt { issue: 1653 }, + )); + } Ok(route_record_between( ctx.ramps, target.before, diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index b3107b972..83f09454c 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -534,6 +534,11 @@ pub struct HostLiveControlHandles { /// `graph_compiler::route_coefficients`, the function the compiler lowers a prepared /// route's constants with. pub route_controls: Vec, + /// One seven-word producer per following route (#1652 D1): a route into a submix whose + /// source strip automates a lane's mute, in canonical route-ID order. Every plan has them, + /// whatever the live options; such a route has no entry in [`Self::route_controls`]. Found + /// with [`follow_route_producer_position`]. + pub follow_route_controls: Vec, /// The designated master strip index, echoed back after validation against `strips.len()` /// (issue #1208 D3): tracks first, then submixes. Still a designation, not a discovery. pub master_track: Option, @@ -575,6 +580,19 @@ impl HostLiveControlHandles { } } +/// The position of the seven-word producer of route `id` in `producers`, which are in canonical +/// route-ID order ([`HostLiveControlHandles::follow_route_controls`]): a binary search, so it +/// allocates nothing. +#[must_use] +pub fn follow_route_producer_position( + producers: &[graph::GraphFollowRouteControlProducer], + id: &str, +) -> Option { + producers + .binary_search_by(|producer| (*producer.route_id).cmp(id)) + .ok() +} + /// The position of strip `id` in the canonical strip order: the tracks, then the submixes, each /// segment in ascending ID order, as [`HostLiveControlHandles::strips`] lists them (#1554 D6). /// `id_at(i)` is the ID of the strip at position `i` of `count`, the first `track_count` of @@ -2822,6 +2840,32 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( ) })?; let builtin_resources = builtins.resource_report(); + // #1652 D1 (#1652c): the source of every following route, read before lowering consumes the + // builtins plan: the plan's one automation program, the source lanes' mute cells and the + // seeded flags word. `terms` is the source lane's effective mute without its curve (OQ1 = C); + // `automated` is the plan's. The arrival `a(tap)` is the compile report's, below. + let mut follow_cells = Vec::new(); + for route in &model.routes { + if !follows_automated_mute(model, route) { + continue; + } + let Some(source) = route_source_strip(model, route) else { + return Err(graph_failure("host.route_control.attach")); + }; + let source_id = route_source_id(route); + // `follows_automated_mute` and the builtins plan read the same rendered mute entries, so + // a following route always has its cells. + let Some((program, cells)) = builtins.strip_mute_cells(source_id.as_str()) else { + return Err(graph_failure("host.route_control.attach")); + }; + let flags = graph::RouteFollowFlags { + mute: route.mute, + follows_mute: route.follows_mute, + terms: effective.strips[source].mute, + automated: cells.map(|cell| cell.is_some()), + }; + follow_cells.push((route.id.as_str(), (program, cells, flags))); + } let mut artifact = GraphCompiler::compile_with_builtins(GraphBuiltinsCompileRequest { dispatch: backend, plan_id: 1, @@ -2866,26 +2910,62 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( })?; // Issue #1221 D1: one live lane per route into a submix, attached before bind and before the // caps below, so its charge is refused by the same checks as the effect channels. Without a - // control channel nothing is attached and the plan binds exactly what it bound before. - let graph_route_controls = if attach_live && lanes.routes { - // A route's lane is a latest-target cell with no depth (#1347). + // control channel no six-word lane is attached. #1652 D1 (#1652c): a following route gets + // its seven-word lane on every plan, whatever the live options, and no six-word lane. A plan + // with neither binds exactly what it bound before. + let six_word = attach_live && lanes.routes; + let (graph_route_controls, follow_route_controls) = if six_word || !follow_cells.is_empty() { + let arrivals = artifact.report().input_arrivals.clone(); + let mut sources: BTreeMap<&str, _> = follow_cells.into_iter().collect(); artifact - .attach_route_live_controls(&live_superseded) + .attach_route_live_controls_following(&live_superseded, six_word, |route_id| { + let (program, cells, flags) = sources.remove(route_id.as_str())?; + let node = GraphNodeId::Route { + route_id: route_id.clone(), + }; + graph::GraphFollowRouteSource::new( + program, + cells, + graph::floored_input_arrival(&arrivals, &node), + follow_mute_jump, + flags, + ) + }) .map_err(|error| match error { graph::GraphRouteControlError::AlreadyAttached | graph::GraphRouteControlError::BoundRoute => { graph_failure("host.route_control.attach") } + }) + .and_then(|attached| { + // Every following route is a route into a submix, so the attach asks for each + // source once, and `new` refuses none (its cells are the program's). + if sources.is_empty() { + Ok(attached) + } else { + Err(graph_failure("host.route_control.attach")) + } })? } else { - Vec::new() + (Vec::new(), Vec::new()) }; - let route_control_resources = graph::route_control_resources(&graph_route_controls, &[]); + if follow_route_controls.len() + != model + .routes + .iter() + .filter(|route| follows_automated_mute(model, route)) + .count() + { + return Err(graph_failure("host.route_control.attach")); + } + let route_control_resources = + graph::route_control_resources(&graph_route_controls, &follow_route_controls); // The producers in canonical route-ID order; a carried send's retarget is written through them. let mut route_controls: Vec = graph_route_controls .into_iter() .map(RouteControlProducer::new) .collect(); + let mut follow_route_controls = follow_route_controls; // #1285 D4: the floored output arrival, the one the plan renders. let output_latency = artifact.report().output_latency; let output_tail = artifact.report().output_tail; @@ -3651,11 +3731,26 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( // Issue #1284 D3: the carried sends' live differences, through the successor's own // producers, in the same block as the strips', with the ramp the record carries. A write // to a latest-target cell cannot fail (#1347), and every send of the table has a producer. + // #1652 D3 (#1652c): a seven-word successor's record goes to its follow producer. The + // join builds the record kind from the model the successor was prepared from, so each + // record has its producer. for &(route_id, record) in &route_carry.retargets { - let Some(position) = route_producer_position(&route_controls, route_id) else { - return Err(graph_failure("host.control.order")); - }; - route_controls[position].write(stamp, record); + match record { + SendRecord::Folded(record) => { + let Some(position) = route_producer_position(&route_controls, route_id) else { + return Err(graph_failure("host.control.order")); + }; + route_controls[position].write(stamp, record); + } + SendRecord::Following(record) => { + let Some(position) = + follow_route_producer_position(&follow_route_controls, route_id) + else { + return Err(graph_failure("host.control.order")); + }; + follow_route_controls[position].write(stamp, record); + } + } } for retarget in &strip_carry.retargets { let Some(&index) = canonical_index.get(retarget.id) else { @@ -3717,6 +3812,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( meters, effect_observations, route_controls, + follow_route_controls, master_track, live_values_superseded: live_superseded, jump_lengths: Some(jump_lengths), @@ -4192,6 +4288,110 @@ pub(crate) fn route_record_between( build(after, gates[1], length).map(Some) } +/// The record a send's cell takes (#1652 D1): the six folded words of #1347, or, for a route a +/// plan gives the seven-word cell ([`follows_automated_mute`]), its open transform and flags. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum SendRecord { + /// A six-word route's record ([`route_record_between`]). + Folded(RouteControlRecord), + /// A following route's record ([`follow_route_record_between`]). + Following(graph::FollowRouteControlRecord), +} + +/// The seven-word record of a following route (#1652 D1) as `route` holds it: the open transform +/// (`graph_compiler::route_values`, the derivation the compiler lowers the plan's transform +/// with, so a record's words and the plan's are one bit pattern), the flags word (the route's +/// `mute` and `follows_mute`, the source lanes' `terms` and the plan's `automated`) and `length`. +/// +/// # Errors +/// +/// [`LiveRebuild::Domain`] when the gain or matrix is outside the route domain, or `length` is +/// longer than the cell's maximum. +pub(crate) fn follow_route_record( + route: &session::Route, + terms: [bool; 2], + automated: [bool; 2], + length: u32, +) -> Result { + let matrix = &route.channel_matrix; + let transform = + graph_compiler::route_values(route.gain_db, [matrix.ll, matrix.lr, matrix.rl, matrix.rr]) + .map_err(|_| LiveRebuild::Domain)?; + let flags = graph::RouteFollowFlags { + mute: route.mute, + follows_mute: route.follows_mute, + terms, + automated, + }; + graph::FollowRouteControlRecord::new(transform, flags, length).ok_or(LiveRebuild::Domain) +} + +/// The seven-word record that brings a following route from `before`'s committed values to +/// `after`'s (#1652 D1, D3), the counterpart of [`route_record_between`] for a route the next plan +/// gives the seven-word cell. The classifier (#1653) and a successor's send join call it, so the +/// two cannot disagree. +/// +/// `terms` is the source lane's effective mute without its curve per model, in `[before, +/// after]` (`controls::effective`, OQ1 = C); `automated` is the next plan's. A record is built only +/// when a word the models hold changes: the transform's bits, `mute`, `follows_mute` or `terms`. +/// `automated` is the plan's, not the model's, so a route that only gains or loses its curve +/// gives no record (render compares the carried ramp with its own target, #1652 D3). +/// +/// The ramp is the send rule of [`route_record_between`]: `gate_length` when the effective gate +/// changes in either lane, else the `SendFollowsMute` length when only `follows_mute` changes (the +/// curve can make that a change of target), else the longest of the changed `SendGain` and +/// `SendMatrix` lengths, else the `SendMute` length. +/// +/// # Errors +/// +/// [`LiveRebuild::Domain`] as [`follow_route_record`]. +#[allow(clippy::too_many_arguments)] +pub(crate) fn follow_route_record_between( + ramps: LiveRamps, + before: &session::Route, + after: &session::Route, + terms: [[bool; 2]; 2], + automated: [bool; 2], + gates: [[bool; 2]; 2], + gate_length: u32, + edit: RouteEditRamps, +) -> Result, LiveRebuild> { + let prior = follow_route_record(before, terms[0], automated, 0)?; + let current = follow_route_record(after, terms[1], automated, 0)?; + let transform_bits = |record: &graph::FollowRouteControlRecord| { + let graph::RouteTransform { + gain, + ll, + lr, + rl, + rr, + } = record.transform(); + [gain, ll, lr, rl, rr].map(f32::to_bits) + }; + if transform_bits(&prior) == transform_bits(¤t) && prior.flags() == current.flags() { + return Ok(None); + } + let matrix = |route: &session::Route| { + let matrix = &route.channel_matrix; + [matrix.ll, matrix.lr, matrix.rl, matrix.rr].map(f32::to_bits) + }; + let length = if gates[0] != gates[1] { + gate_length + } else if before.follows_mute != after.follows_mute { + ramps.resolve(LiveRampRow::SendFollowsMute, edit.mute) + } else { + let gain = (before.gain_db.to_bits() != after.gain_db.to_bits()) + .then(|| ramps.resolve(LiveRampRow::SendGain, edit.gain)); + let matrix = (matrix(before) != matrix(after)) + .then(|| ramps.resolve(LiveRampRow::SendMatrix, edit.matrix)); + gain.into_iter() + .chain(matrix) + .max() + .unwrap_or_else(|| ramps.resolve(LiveRampRow::SendMute, edit.mute)) + }; + follow_route_record(after, terms[1], automated, length).map(Some) +} + /// A fader or pan value outside the domain its render-side setter accepts. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct OutOfDomain; @@ -4742,6 +4942,15 @@ impl<'a> EffectiveDiff<'a> { ) } + /// The effective mute of a strip per lane, at `before` in `current` and at `after` in `next`, + /// in `[current, next]`: a following route's `terms` (#1652 D1, OQ1 = C; it holds no curve). + pub(crate) fn strip_mutes(&self, before: usize, after: usize) -> [[bool; 2]; 2] { + [ + self.before.strips[before].mute, + self.after.strips[after].mute, + ] + } + /// The strip index of a route's source in the two models, or `None` if it names no strip in /// one of them. pub(crate) fn source_strips( @@ -4756,14 +4965,49 @@ impl<'a> EffectiveDiff<'a> { } } +/// The ID of the strip `route` takes its source from. +fn route_source_id(route: &session::Route) -> &session::StableId { + match &route.source { + session::RouteSource::Track { track_id, .. } => track_id, + session::RouteSource::Submix { submix_id, .. } => submix_id, + } +} + +/// Whether a plan of `model` gives `route` the seven-word cell of a following route (#1652 D1): +/// it goes into a submix and its source strip automates the mute of a lane +/// ([`controls::mute_automation`], the entries the builtins plan compiles mute cells from). Every +/// other route keeps the six-word cell. A route into the output never follows. +#[must_use] +pub(crate) fn follows_automated_mute(model: &SessionModel, route: &session::Route) -> bool { + matches!( + route.destination, + session::RouteDestination::SubmixInput { .. } + ) && !model.automation.is_empty() + && controls::mute_automation(model, route_source_id(route)) + .iter() + .any(Option::is_some) +} + +/// The jump length of a stored mute curve on a following route (#1652 D2, #1652b): the mute row's +/// rule over the block's session lengths, the rule the strip's own mute stage resolves +/// ([`LiveRamps::for_row`] of [`LiveRampRow::Mute`]), so the strip ramp and the send ramp have +/// one length. Called on render (through the route's `GraphMuteJumpLength`): two `const fn` +/// calls, a struct build and a `match` read, allocation-free and panic-free. +// REALTIME_POLICY_BEGIN: the mute row's jump rule a following route calls once per block. +fn follow_mute_jump(lengths: GraphJumpLengths) -> u32 { + LiveRamps::from_lengths( + lengths.fader_samples, + lengths.mute_samples, + lengths.pan_samples, + ) + .for_row(LiveRampRow::Mute) +} +// REALTIME_POLICY_END + /// The index in [`SessionModel::strips`] of the strip `route` takes its source from, or `None` if /// the model has no such strip. pub(crate) fn route_source_strip(model: &SessionModel, route: &session::Route) -> Option { - let id = match &route.source { - session::RouteSource::Track { track_id, .. } => track_id, - session::RouteSource::Submix { submix_id, .. } => submix_id, - }; - controls::strip_index(model, id) + controls::strip_index(model, route_source_id(route)) } /// The matrix record that brings a strip's matrix/pan stage from `before`'s value to `after`'s, @@ -5458,7 +5702,7 @@ fn resolve_strip_delay_carry( /// carried send from the predecessor's live values to the successor's, when they differ. struct RouteJoin<'a> { id: &'a str, - retarget: Option, + retarget: Option, } /// The model half of the send join (#1284 D2, D3): for every route of `model` into a submix that @@ -5512,8 +5756,31 @@ fn join_routes<'a>( }; length }; - let Ok(retarget) = route_record_between(ramps, before, after, gates, gate_length, edit) - else { + // #1652 D3 (#1652c): a route the successor gives the seven-word cell takes the + // seven-word record, written only when a word the models hold changes; any other route + // keeps today's record. + let retarget = if follows_automated_mute(model, after) { + let Some(source) = diff.source_strips(before, after) else { + continue; + }; + let automated = controls::mute_automation(model, route_source_id(after)) + .map(|entry| entry.is_some()); + follow_route_record_between( + ramps, + before, + after, + diff.strip_mutes(source.0, source.1), + automated, + gates, + gate_length, + edit, + ) + .map(|record| record.map(SendRecord::Following)) + } else { + route_record_between(ramps, before, after, gates, gate_length, edit) + .map(|record| record.map(SendRecord::Folded)) + }; + let Ok(retarget) = retarget else { continue; }; joins.push(RouteJoin { @@ -5530,7 +5797,7 @@ struct RouteCarry<'a> { moves: Vec, /// The carried sends whose live values differ, by route ID, with the record to write to the /// successor's cell. - retargets: Vec<(&'a str, RouteControlRecord)>, + retargets: Vec<(&'a str, SendRecord)>, } /// The route half of the send join (#1284 D2): a send carries when both plans hold a live route of @@ -6300,9 +6567,10 @@ mod tests { }; let before = [live("a-main", 3), live("b-main", 5)]; let own = [live("a-main", 4), live("b-main", 2), live("c-main", 9)]; - let record = + let record = super::SendRecord::Folded( crate::route_controls::route_record(-6.0, [1.0, 0.0, 0.0, 1.0], false, [false; 2], 64) - .expect("a record"); + .expect("a record"), + ); let joins = [ RouteJoin { id: "a-main", diff --git a/crates/host-core/tests/effect_observation.rs b/crates/host-core/tests/effect_observation.rs index 605c12a2e..96c09120c 100644 --- a/crates/host-core/tests/effect_observation.rs +++ b/crates/host-core/tests/effect_observation.rs @@ -138,6 +138,7 @@ fn prepare_from(document: &str, leg: Leg) -> Session { meters: Vec::new(), effect_observations: Vec::new(), route_controls: Vec::new(), + follow_route_controls: Vec::new(), master_track: None, live_values_superseded: LiveValuesSuperseded::default(), jump_lengths: None, diff --git a/crates/host-core/tests/live_routes.rs b/crates/host-core/tests/live_routes.rs index a28871ee6..3a0e23120 100644 --- a/crates/host-core/tests/live_routes.rs +++ b/crates/host-core/tests/live_routes.rs @@ -325,6 +325,7 @@ impl Live { meters: Vec::new(), effect_observations: Vec::new(), route_controls: Vec::new(), + follow_route_controls: Vec::new(), master_track: None, live_values_superseded: host_core::LiveValuesSuperseded::default(), jump_lengths: None, diff --git a/crates/host-core/tests/route_mute.rs b/crates/host-core/tests/route_mute.rs index 2311e5b03..ccb5e7e84 100644 --- a/crates/host-core/tests/route_mute.rs +++ b/crates/host-core/tests/route_mute.rs @@ -1407,3 +1407,848 @@ fn a_fully_follow_muted_delayed_send_stays_active() { (follow.mute, follow.follows_mute) = (false, true); assert_delayed_route_stays_active(&delayed_session_checked(model)); } + +// ---- #1652: a following send of a stored mute --------------------------------------------- + +/// #1652 (batch 18, #1652c): a send into a submix whose source strip automates its mute holds the +/// seven-word cell on every plan and follows the curve in render at its tap's arrival. +#[cfg(feature = "test-support")] +#[path = "support/successor.rs"] +mod successor; + +#[cfg(feature = "test-support")] +mod follow { + use super::successor; + use super::{ + ChannelMatrix, Effect, EffectIdentity, EffectQuality, LinkMode, RouteSource, SendTap, + SessionModel, SidechainDeclaration, Submix, add_track, bench_alloc, empty_session, + into_bus, route, sid, to_output, + }; + use engine::realtime::{PlanarBufferMut, RenderIo, RenderTime}; + use graph_compiler::Backend; + use host_core::{HostLiveControlRequest, PreparedHost, SourceSubmission}; + use session::{ + Automation, AutomationSegment, AutomationShape, ControlAddress, ControlSmoothing, Owner, + ParameterChannel, ParameterUnit, Section, + }; + + /// The session mute length: `mute_ms` 10 at 48 kHz. + pub(super) const M: u32 = 480; + /// The mute row (builtin parameter 6). + const MUTE: u32 = 6; + /// The true-peak limiter's prepared latency at 48 kHz: no native effect declares 64. + pub(super) const L: u64 = 486; + + /// Every bank width of the target and the scalar oracle (R17-4). + pub(super) fn backends() -> Vec { + vec![ + #[cfg(target_feature = "avx2")] + Backend::Simd8, + Backend::Simd4, + Backend::Scalar, + ] + } + + /// Track `t` (two-channel source `t`) and bus `b`. `t-main` feeds the output's left plane from + /// `t`'s left lane (`ll` only); the send `t-b` feeds bus `b`'s right plane from `t`'s right + /// lane at `tap` (`rr` only), and `follows_mute` when `follows`; `b-main` takes the bus to + /// the output. So the output's left plane is the strip and its right plane is the send. With + /// `latent`, `t` carries a true-peak limiter insert ([`L`] samples, before the fader). + pub(super) fn model(quantum: u32, tap: SendTap, latent: bool, follows: bool) -> SessionModel { + let (mut model, source, track) = empty_session(); + model.quantum_frames = quantum; + model.control_smoothing = Some(ControlSmoothing { + mute_ms: 10.0, + fader_ms: 5.0, + pan_ms: 5.0, + }); + let mut source = source; + source.frames = 1 << 16; + add_track(&mut model, &source, &track, "t"); + if latent { + model.tracks[0].inserts.effects.push(Effect { + id: sid("ceiling"), + identity: EffectIdentity::Native { + effect_id: sid("miso.true-peak-limiter"), + }, + quality: EffectQuality::Normal, + bypass: false, + link_mode: LinkMode::Maximum, + params: Vec::new(), + sidechain: SidechainDeclaration::None, + }); + } + let mut direct = to_output( + "t-main", + RouteSource::Track { + track_id: sid("t"), + tap: SendTap::PostPan, + }, + ); + direct.channel_matrix = ChannelMatrix { + ll: 1.0, + lr: 0.0, + rl: 0.0, + rr: 0.0, + }; + let mut send = route( + "t-b", + RouteSource::Track { + track_id: sid("t"), + tap, + }, + into_bus("b"), + ); + send.channel_matrix = ChannelMatrix { + ll: 0.0, + lr: 0.0, + rl: 0.0, + rr: 1.0, + }; + send.follows_mute = follows; + model.routes.push(direct); + model.routes.push(send); + model.routes.push(to_output( + "b-main", + RouteSource::Submix { + submix_id: sid("b"), + tap: SendTap::PostPan, + }, + )); + model.submixes.push(Submix::unity(sid("b"), &model.console)); + assert_eq!(controls::LiveRamps::for_session(&model).mute_samples, M); + model + } + + /// A mute entry on `both` lanes of track `t`, from `from`, with a step to each `(sample, + /// value)` of `steps`. + pub(super) fn mute_entry(from: f32, steps: &[(u64, f32)]) -> Automation { + mute_entry_on("t", from, steps) + } + + /// [`mute_entry`] on strip `strip`. + pub(super) fn mute_entry_on(strip: &str, from: f32, steps: &[(u64, f32)]) -> Automation { + let mut at = (0_u64, from); + let mut segments: Vec = steps + .iter() + .map(|&(sample, value)| { + let segment = AutomationSegment { + shape: AutomationShape::Step, + start_sample: at.0, + end_sample: sample, + start_value: at.1, + end_value: value, + unit: ParameterUnit::Linear, + }; + at = (sample, value); + segment + }) + .collect(); + if segments.is_empty() { + segments.push(AutomationSegment { + shape: AutomationShape::Step, + start_sample: 0, + end_sample: 1, + start_value: from, + end_value: from, + unit: ParameterUnit::Linear, + }); + } + Automation { + id: sid("mute-entry"), + target: ControlAddress { + owner: Owner::Strip(sid(strip)), + section: Section::Builtins, + control: MUTE, + lane: ParameterChannel::Both, + }, + segments, + offset: session::AutomationOffset::default(), + } + } + + /// `model` with `t`'s two lanes statically muted. + pub(super) fn statically_muted(mut model: SessionModel) -> SessionModel { + let fader = &mut model.tracks[0].fader; + (fader.left_mute, fader.right_mute) = (true, true); + model + } + + /// A two-plane feed of `frames` samples: DC `value`, or with `value = None` a signed + /// pseudo-random signal that is never zero, half its samples negative (signed zeros show). + pub(super) fn feed(frames: usize, value: Option) -> [Vec; 2] { + let plane = |channel: u32| { + (0..frames as u32) + .map(|frame| { + value.unwrap_or_else(|| { + let step = frame + .wrapping_mul(7_919) + .wrapping_add(channel * 31) + .wrapping_add(17) + % 1_999; + (step as f32 - 999.5) / 2_000.0 + }) + }) + .collect::>() + }; + [plane(0), plane(1)] + } + + /// How a plan is prepared: no live control, live controls, or live controls between render + /// calls (the browser's form). + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub(super) enum Live { + None, + Controls, + Between, + } + + pub(super) const LIVE_FORMS: [Live; 3] = [Live::None, Live::Controls, Live::Between]; + + /// `model` prepared on `backend` in form `live`. + pub(super) fn prepare( + model: &SessionModel, + backend: Backend, + live: Live, + ) -> (PreparedHost, Option) { + let document = super::document(model); + let caps = host_core::HostPrepareCaps { + source_ring_frames: 32 * model.quantum_frames, + ..super::caps() + }; + let compiled = host_core::compile_host_session(&document, &caps) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + let request = HostLiveControlRequest::default(); + let fail = |failure: host_core::PrepareDiagnostics| -> ! { + panic!("prepare: {}", String::from_utf8_lossy(failure.as_bytes())) + }; + match live { + Live::None => ( + host_core::test_only_prepare_host_runtime_on(&compiled, &caps, backend) + .unwrap_or_else(|failure| fail(failure)), + None, + ), + Live::Controls => { + let (prepared, handles) = + host_core::test_only_prepare_host_runtime_with_live_controls_on( + &compiled, &caps, &request, backend, + ) + .unwrap_or_else(|failure| fail(failure)); + (prepared, Some(handles)) + } + Live::Between => { + let (prepared, handles) = + host_core::prepare::test_only_prepare_host_runtime_between_render_calls_on( + &compiled, &caps, &request, backend, + ) + .unwrap_or_else(|failure| fail(failure)); + (prepared, Some(handles)) + } + } + } + + /// What one run rendered: the output planes and the render calls' allocator counts after the + /// first block, `(allocations, reallocations, deallocations)`. + pub(super) struct Run { + pub(super) out: [Vec; 2], + pub(super) allocations: (u64, u64, u64), + pub(super) follow_routes: usize, + } + + /// `model` on `backend` in form `live`, fed `pcm` on source `t`, for `frames` samples. + pub(super) fn run( + model: &SessionModel, + backend: Backend, + live: Live, + pcm: &[Vec; 2], + frames: usize, + ) -> Run { + let quantum = model.quantum_frames as usize; + let (mut prepared, handles) = prepare(model, backend, live); + let follow_routes = handles + .as_ref() + .map_or(usize::MAX, |handles| handles.follow_route_controls.len()); + let rate = prepared.report.sample_rate_hz; + let mut out = [Vec::new(), Vec::new()]; + let mut allocations = (0, 0, 0); + bench_alloc::assert_installed(); + let mut at = 0; + while at < frames { + prepared + .sources + .submit( + b"t", + SourceSubmission { + generation: 1, + start_frame: at as u64, + sample_rate_hz: rate, + planes: &[&pcm[0][at..at + quantum], &pcm[1][at..at + quantum]], + frames: quantum as u32, + end_of_region: false, + }, + ) + .expect("source block"); + let mut samples = vec![0.0_f32; quantum * 2]; + let output = + PlanarBufferMut::try_new(&mut samples, 2, quantum, quantum).expect("output planes"); + let mark = bench_alloc::current_thread_counters(); + prepared + .plan + .render( + RenderIo { output }, + RenderTime { + absolute_sample: at as u64, + }, + ) + .expect("render"); + let delta = bench_alloc::current_thread_delta_since(mark); + if at > 0 { + allocations.0 += delta.allocations; + allocations.1 += delta.reallocations; + allocations.2 += delta.deallocations; + } + out[0].extend_from_slice(&samples[..quantum]); + out[1].extend_from_slice(&samples[quantum..]); + at += quantum; + } + drop(handles); + Run { + out: out.map(|mut plane| { + plane.truncate(frames); + plane + }), + allocations, + follow_routes, + } + } + + /// The first index at or after `from` where `a` and `b` differ by bits. + pub(super) fn first_difference_from(a: &[f32], b: &[f32], from: usize) -> Option { + (from..a.len().min(b.len())).find(|&index| a[index].to_bits() != b[index].to_bits()) + } + + /// The first index at or after `from` where `plane` is exact `+0.0`. + pub(super) fn first_zero_from(plane: &[f32], from: usize) -> Option { + (from..plane.len()).find(|&index| plane[index].to_bits() == 0) + } + + /// A predecessor of `a` with live controls rendered for [`successor::SWAP_BLOCK`] blocks (with + /// `edit` run before each block's render, writing through its handles and gate), then a + /// successor of `b` prepared from the predecessor's inventory and committed model + /// `committed`, swapped in and rendered to [`successor::BLOCKS`]; or, with `b = None`, the + /// predecessor alone for every block (the never-swapped twin). Fed `pcm` on source `t`. + pub(super) fn live_swap( + a: &SessionModel, + b: Option<(&SessionModel, &SessionModel)>, + backend: Backend, + pcm: &successor::Feed, + mut edit: impl FnMut( + usize, + &mut host_core::HostLiveControlHandles, + &mut engine::realtime::PlanRevisionGate, + ), + ) -> ( + Vec, + Option, + ) { + use successor::{BLOCKS, SWAP_BLOCK}; + let request = HostLiveControlRequest::default(); + let caps = successor::caps(); + let session = successor::Session::compile(a.clone()); + let (prepared, mut handles) = + host_core::test_only_prepare_host_runtime_with_live_controls_on( + &session.compiled, + &caps, + &request, + backend, + ) + .unwrap_or_else(|failure| panic!("prepare: {failure:?}")); + let rate = prepared.report.sample_rate_hz; + let PreparedHost { + mut plan, + sources: mut a_sources, + inventory, + .. + } = prepared; + let mut gate = plan.take_revision_gate().expect("the plan's gate"); + let (mut publisher, mut owner, _retirer) = successor::exchange(plan); + pcm.submit(&mut a_sources, rate, 0); + let mut blocks = Vec::with_capacity(BLOCKS); + let swap_at = if b.is_some() { SWAP_BLOCK } else { BLOCKS }; + for block in 0..swap_at { + if block + 1 < BLOCKS { + pcm.submit(&mut a_sources, rate, block + 1); + } + edit(block, &mut handles, &mut gate); + blocks.push(successor::render(&mut owner)); + } + let Some((b, committed)) = b else { + return (blocks, Some(handles)); + }; + let next = successor::Session::compile(b.clone()); + let committed = successor::Session::compile(committed.clone()).model; + let revision = gate.last_published() + 1; + let (mut prepared, successor_handles) = + host_core::test_only_prepare_host_runtime_with_live_controls_successor_on( + &next.compiled, + &caps, + &request, + host_core::SuccessorBase::new(&inventory, &committed, revision), + backend, + ) + .unwrap_or_else(|failure| panic!("successor: {failure:?}")); + prepared.sources.adopt_persisting(&mut a_sources); + let PreparedHost { + mut plan, + sources: mut b_sources, + .. + } = prepared; + plan.take_revision_gate() + .expect("the successor's gate") + .publish(revision); + publisher + .reserve_replacement(plan, engine::realtime::PlanAdoption::Next) + .unwrap_or_else(|_| panic!("reserve the successor")) + .commit(); + for block in SWAP_BLOCK..BLOCKS { + if block + 1 < BLOCKS { + pcm.submit(&mut b_sources, rate, block + 1); + } + blocks.push(successor::render(&mut owner)); + } + drop(handles); + (blocks, Some(successor_handles)) + } + + /// Plane `plane` of a run's blocks as samples. + pub(super) fn plane(blocks: &[successor::Block], plane: usize) -> Vec { + let quantum = successor::QUANTUM; + blocks + .iter() + .flat_map(|block| { + block.bits[plane * quantum..(plane + 1) * quantum] + .iter() + .map(|bits| f32::from_bits(*bits)) + }) + .collect() + } + + /// Every render call after block 0 allocated and freed nothing, by the thread counters and + /// the render audit. + pub(super) fn assert_quiet(blocks: &[successor::Block], what: &str) { + for (index, block) in blocks.iter().enumerate().skip(1) { + assert_eq!( + (block.allocator, block.audit), + ((0, 0, 0), (0, 0)), + "{what}: block {index} allocated or freed" + ); + } + } + + /// `sample` rounded up to a block boundary of `quantum`. + pub(super) const fn next_boundary(sample: usize, quantum: usize) -> usize { + sample.div_ceil(quantum) * quantum + } +} + +/// #1652 gate 1 (host-core form, both lanes, every bank width and the scalar oracle, every live +/// form). Track `t` sends `pre_fader` with `follows_mute` into bus `b`; `t` has a mute entry on +/// `both` with steps 0, 1, 0 at samples 1,000 and 2,500 (not grid samples), fed a signed signal. +/// From the first block boundary after the end of the mute ramp to the unmute, the output is bit +/// for bit a plan with `t` statically muted, and from the first block boundary after the end of +/// the unmute ramp it is bit for bit a plan with no mute, at quanta 128 and 100 (the send has no +/// compensation delay). Each plan has exactly one following route, and render allocates nothing. +/// +/// Test value: red if a following send ignores the curve (its pre-fader audio leaks while the +/// strip is muted), if a plan without live controls attaches no lane for it, or if the send does +/// not reopen after the unmute. +#[cfg(feature = "test-support")] +#[test] +fn a_following_send_leaks_nothing_of_a_stored_mute() { + use follow::*; + const FRAMES: usize = 4_000; + const DOWN: usize = 1_000; + const UP: usize = 2_500; + let pcm = feed(FRAMES + 256, None); + for quantum in [128_usize, 100] { + let base = model(quantum as u32, SendTap::PreFader, false, true); + let mut automated = base.clone(); + automated.automation = vec![mute_entry(0.0, &[(DOWN as u64, 1.0), (UP as u64, 0.0)])]; + let muted = statically_muted(base.clone()); + let mut leaking = automated.clone(); + leaking.routes[1].follows_mute = false; + assert_eq!(leaking.routes[1].id.as_str(), "t-b"); + for backend in follow::backends() { + for live in LIVE_FORMS { + let what = format!("q={quantum} {backend:?} {live:?}"); + let curve = run(&automated, backend, live, &pcm, FRAMES); + if live != Live::None { + assert_eq!(curve.follow_routes, 1, "{what}: one following route"); + } + assert_eq!(curve.allocations, (0, 0, 0), "{what}: render allocates"); + let fixed = run(&muted, backend, live, &pcm, FRAMES); + let open = run(&base, backend, live, &pcm, FRAMES); + let muted_window = next_boundary(DOWN + M as usize, quantum)..UP; + let open_window = next_boundary(UP + M as usize, quantum)..FRAMES; + for plane in 0..2 { + assert_bits_equal( + &curve.out[plane][muted_window.clone()], + &fixed.out[plane][muted_window.clone()], + &format!("{what}: plane {plane} while muted"), + ); + assert_bits_equal( + &curve.out[plane][open_window.clone()], + &open.out[plane][open_window.clone()], + &format!("{what}: plane {plane} after the unmute"), + ); + } + // Not vacuous: without the follow the pre-fader send leaks while muted. + let leak = run(&leaking, backend, live, &pcm, FRAMES); + assert!( + first_difference_from(&leak.out[1], &fixed.out[1], muted_window.start) + .is_some_and(|index| index < UP), + "{what}: without the follow the send leaks" + ); + } + } + } +} + +/// #1652 gate 2 (every bank width and the scalar oracle, quanta 128 and 100). Track `t` carries a +/// latent insert of `L` samples before its fader (a true-peak limiter, `L = 486` at 48 kHz: no +/// native effect declares 64) and a mute entry on `both` with steps at 1,000 and 2,500, fed DC. +/// The output's left plane is the strip, its right plane the send into bus `b`. With an `input` +/// tap the send reads `t` `L` render samples before the strip's fader does, and PDC delays the +/// bus path by `L` at the output; with a `post_fader` tap both read at the fader's arrival. In both +/// cases the send's gate change and the strip's mute ramp start on the same output sample, the +/// one where the strip's audio of timeline sample 1,000 arrives (and of 2,500 for the unmute), and +/// with an `input` tap they reach exact `+0.0` after the same length. +/// +/// Test value: red if the route op times its events at the fader's arrival (an `input` tap would +/// gate `L` samples late at the output and leak) or at the route's own arrival, or if the send +/// ramp and the strip ramp differ in length. +#[cfg(feature = "test-support")] +#[test] +fn the_send_gates_on_the_timeline_sample_the_strip_mutes() { + use follow::*; + const FRAMES: usize = 4_000; + let pcm = feed(FRAMES + 256, Some(0.25)); + for quantum in [128_usize, 100] { + for tap in [SendTap::Input, SendTap::PostFader] { + let with_curve = |follows: bool| { + let mut model = model(quantum as u32, tap, true, follows); + model.automation = vec![mute_entry(0.0, &[(1_000, 1.0), (2_500, 0.0)])]; + model + }; + let following = with_curve(true); + let still = with_curve(false); + let plain = model(quantum as u32, tap, true, true); + let muted = statically_muted(plain.clone()); + for backend in follow::backends() { + let what = format!("q={quantum} {tap:?} {backend:?}"); + let a = run(&following, backend, Live::None, &pcm, FRAMES); + let b = run(&still, backend, Live::None, &pcm, FRAMES); + let c = run(&plain, backend, Live::None, &pcm, FRAMES); + let d = run(&muted, backend, Live::None, &pcm, FRAMES); + // The mute against the open twins (the strip with no curve, the send with no + // follow); the unmute against the statically muted twin, strip and send. + for (from, event, strip_twin, send_twin) in + [(0, "mute", &c, &b), (2_000, "unmute", &d, &d)] + { + let strip = first_difference_from(&a.out[0], &strip_twin.out[0], from) + .unwrap_or_else(|| panic!("{what}: the strip {event}s")); + let send = first_difference_from(&a.out[1], &send_twin.out[1], from) + .unwrap_or_else(|| panic!("{what}: the send {event}s")); + assert_eq!( + send, strip, + "{what}: the send's {event} starts on the strip's output sample" + ); + let step = if event == "mute" { 1_000 } else { 2_500 }; + assert!( + (step + L as usize..step + L as usize + 2).contains(&strip), + "{what}: the {event} lands where timeline {step} arrives, at {strip}" + ); + } + if tap == SendTap::Input { + let strip = first_difference_from(&a.out[0], &c.out[0], 0).expect("mute"); + let send = first_difference_from(&a.out[1], &b.out[1], 0).expect("mute"); + let strip_end = first_zero_from(&a.out[0], strip).expect("the strip mutes"); + let send_end = first_zero_from(&a.out[1], send).expect("the send mutes"); + assert_eq!( + send_end - send, + strip_end - strip, + "{what}: the two mute ramps have one length" + ); + assert!( + (M as usize - 1..=M as usize).contains(&(send_end - send)), + "{what}: the length is the session's mute length, {}", + send_end - send + ); + } + } + } + } +} + +/// The send gain record a host's commit writes for `t-b` going to `gain_db` over `length`, its +/// right-to-right matrix kept and the gate open. +#[cfg(feature = "test-support")] +fn send_gain(gain_db: f32, length: u32) -> host_core::RouteControlRecord { + host_core::route_record(gain_db, [0.0, 0.0, 0.0, 1.0], false, [false; 2], length) + .expect("an in-domain send record") +} + +/// #1652 gate 4(a) (every bank width and the scalar oracle). A predecessor with live controls +/// and no stored automation ramps its send `t-b` toward `-12` dB over 2,000 samples from block 4. +/// A rebuild at block 6 adds a mute entry on `t` (held unmuted, then a step to muted at 1,000): +/// the successor gives `t-b` the seven-word cell, so the route changes kind. It keeps the send's +/// ramp by route ID: until the step, the swapped run is bit for bit the never-swapped +/// predecessor. The join writes no seven-word record (no word the models hold changed), the swap +/// block and every later block allocate and free nothing, and from the step the send follows. +/// +/// Test value: red if gaining automation on the source re-points the send and drops its ramp +/// (the successor starts at its prepared `-12` dB), if the join writes a record only because the +/// kind changed, or if the swap allocates. +#[cfg(feature = "test-support")] +#[test] +fn a_rebuild_that_adds_a_mute_entry_keeps_the_sends_ramp() { + use follow::*; + use successor::{QUANTUM, SWAP_BLOCK}; + let pcm = successor::Feed::new("t", 11); + let a = model(QUANTUM as u32, SendTap::PreFader, false, true); + let mut committed = a.clone(); + committed.routes[1].gain_db = -12.0; + assert_eq!(committed.routes[1].id.as_str(), "t-b"); + let mut b = committed.clone(); + b.automation = vec![mute_entry(0.0, &[(1_000, 1.0)])]; + let ramp = |block: usize, + handles: &mut host_core::HostLiveControlHandles, + gate: &mut engine::realtime::PlanRevisionGate| { + use builtins_compiler::TestOnlyWriteGate; + if block == 4 { + let position = host_core::route_producer_position(&handles.route_controls, "t-b") + .expect("t-b is a six-word route of the predecessor"); + gate.write_published(|stamp| { + handles.route_controls[position].write(stamp, send_gain(-12.0, 2_000)); + }); + } + }; + for backend in follow::backends() { + let what = format!("{backend:?}"); + let (twin, _) = live_swap(&a, None, backend, &pcm, ramp); + let (swapped, handles) = live_swap(&a, Some((&b, &committed)), backend, &pcm, ramp); + let handles = handles.expect("the successor's handles"); + assert_eq!(handles.follow_route_controls.len(), 1, "{what}"); + assert!( + host_core::route_producer_position(&handles.route_controls, "t-b").is_none(), + "{what}: a following route has no six-word lane" + ); + assert_eq!( + handles.follow_route_controls[0].test_only_write_count(), + 0, + "{what}: the join writes no record for a change of kind" + ); + assert_quiet(&swapped, &what); + let step = 1_000; + for index in 0..2 { + let (swapped, twin) = (plane(&swapped, index), plane(&twin, index)); + assert_bits_equal( + &swapped[..step], + &twin[..step], + &format!("{what}: plane {index} before the step"), + ); + // The ramp is still moving at the swap, so the comparison is not vacuous. + let at = SWAP_BLOCK * QUANTUM; + if index == 1 { + assert_ne!( + swapped[at] / pcm.planes[1][at], + swapped[step - 1] / pcm.planes[1][step - 1], + "{what}: the send ramps through the swap" + ); + assert!( + swapped[step + M as usize..] + .iter() + .all(|sample| *sample == 0.0), + "{what}: the send follows the successor's curve" + ); + } + } + } +} + +/// #1652 gate 4(b) (every bank width and the scalar oracle). A predecessor whose mute entry holds +/// `t` muted (so its seven-word send `t-b` is follow-muted) is rebuilt at block 6 without the +/// entry: the successor's `t-b` is a six-word route again, and nothing in the two models differs +/// for it, so the join writes nothing. The send reopens over the session mute length from the +/// swap block: silent before it, ramping inside `[768, 768 + M)`, and from the next block +/// boundary the successor prepared fresh, bit for bit. The swap allocates and frees nothing. +/// +/// Test value: red if the six-word successor keeps the adopted mute (the send stays silent), or +/// steps open instead of ramping, or if the swap allocates. +#[cfg(feature = "test-support")] +#[test] +fn a_rebuild_that_removes_the_mute_entry_reopens_the_send() { + use follow::*; + use successor::{BLOCKS, QUANTUM, SWAP_BLOCK}; + let pcm = successor::Feed::new("t", 13); + let b = model(QUANTUM as u32, SendTap::PreFader, false, true); + let mut a = b.clone(); + a.automation = vec![mute_entry(1.0, &[])]; + let quiet = |_: usize, + _: &mut host_core::HostLiveControlHandles, + _: &mut engine::realtime::PlanRevisionGate| {}; + for backend in follow::backends() { + let what = format!("{backend:?}"); + let (swapped, handles) = live_swap(&a, Some((&b, &a)), backend, &pcm, quiet); + let handles = handles.expect("the successor's handles"); + assert!(handles.follow_route_controls.is_empty(), "{what}"); + assert_quiet(&swapped, &what); + let send = plane(&swapped, 1); + let at = SWAP_BLOCK * QUANTUM; + assert!( + send[..at].iter().all(|sample| *sample == 0.0), + "{what}: the send is follow-muted before the swap" + ); + let ramp = &send[at..at + M as usize]; + assert!( + ramp.iter().any(|sample| *sample != 0.0), + "{what}: the send reopens from the swap block" + ); + let open = next_boundary(at + M as usize, QUANTUM); + assert!( + ramp.iter() + .zip(&pcm.planes[1][at..]) + .any(|(sample, source)| sample.to_bits() != source.to_bits()), + "{what}: the send ramps open, it does not step" + ); + let fresh = + successor::reference_run(&successor::Session::compile(b.clone()), &[&pcm], backend); + let fresh = plane(&fresh, 1); + assert_bits_equal( + &send[open..BLOCKS * QUANTUM], + &fresh[open..BLOCKS * QUANTUM], + &format!("{what}: open after the ramp"), + ); + } +} + +/// #1652 gate 4, the combined transaction (coordinator obligation 3). One rebuild at block 6 +/// changes the send's gain and adds a mute entry that holds `t` muted from the start: the strip +/// and the send both adopt the curve's mute at the swap, over the same session mute length, so the +/// strip's lane and the send reach exact `+0.0` on the same output sample, `M` samples after the +/// swap block starts. +/// +/// Test value: red if the join's seven-word record (the gain edit's `SendGain` length) governs the +/// send's mute instead of the curve's own jump over the mute length, or if the strip and the send +/// adopt over different lengths. +#[cfg(feature = "test-support")] +#[test] +#[ignore = "#1652 graph fold (U2a): the carried record is composed with the heard curve there"] +fn a_send_edit_and_a_mute_entry_in_one_rebuild_adopt_over_one_length() { + use follow::*; + use successor::{QUANTUM, SWAP_BLOCK}; + let pcm = successor::Feed::new("t", 17); + let a = model(QUANTUM as u32, SendTap::PreFader, false, true); + let mut b = a.clone(); + b.routes[1].gain_db = -6.0; + b.automation = vec![mute_entry(1.0, &[])]; + let quiet = |_: usize, + _: &mut host_core::HostLiveControlHandles, + _: &mut engine::realtime::PlanRevisionGate| {}; + for backend in follow::backends() { + let what = format!("{backend:?}"); + let (swapped, _) = live_swap(&a, Some((&b, &a)), backend, &pcm, quiet); + assert_quiet(&swapped, &what); + let at = SWAP_BLOCK * QUANTUM; + let strip = first_zero_from(&plane(&swapped, 0), at); + let send = first_zero_from(&plane(&swapped, 1), at); + eprintln!("{what}: strip zero at {strip:?}, send zero at {send:?}"); + let (strip, send) = ( + strip.expect("the strip mutes"), + send.expect("the send mutes"), + ); + assert_eq!(send, strip, "{what}: one length for the strip and the send"); + assert!( + (at + M as usize - 1..=at + M as usize).contains(&send), + "{what}: over the mute length, at {send}" + ); + } +} + +/// Coordinator obligation 4: the following route's charge covers what its attach and bind +/// retain. Two tracks, `t` (which sends into bus `b`) and `u`, prepared with no live control, so +/// no six-word lane is attached: a mute entry on `t` gives `t-b` its seven-word lane, the same +/// entry on `u` gives no route a lane. The plans differ only in that lane (and the route-activity +/// table its plan builds), and the bytes the first plan retains beyond the second are within the +/// report's `route_control_resources`, whose one route is charged a seven-word cell. +/// +/// Test value: red if the seven-word cell, its owner, its binding, its producer or the activity +/// table it makes the plan build is left out of the charge a host admits against its caps. +#[cfg(feature = "test-support")] +#[test] +fn the_following_route_charge_covers_its_attach_and_bind() { + use follow::*; + let mut base = model(128, SendTap::PreFader, false, true); + let (_, source, track) = empty_session(); + let mut source = source; + source.frames = 1 << 16; + add_track(&mut base, &source, &track, "u"); + base.routes.push(to_output( + "u-main", + RouteSource::Track { + track_id: sid("u"), + tap: SendTap::PostPan, + }, + )); + let mut following = base.clone(); + following.automation = vec![mute_entry_on("t", 1.0, &[(1_000, 0.0)])]; + let mut elsewhere = base; + elsewhere.automation = vec![mute_entry_on("u", 1.0, &[(1_000, 0.0)])]; + launch_native_effect_registry().expect("the launch registry builds"); + bench_alloc::assert_installed(); + let retained = |model: &SessionModel| { + let caps = caps(); + let compiled = compile_host_session(&document(model), &caps).expect("compile"); + // Warm the process statics a first preparation initialises. + drop(host_core::test_only_prepare_host_runtime_on( + &compiled, + &caps, + Backend::Simd4, + )); + let mark = bench_alloc::current_thread_counters(); + let prepared = + host_core::test_only_prepare_host_runtime_on(&compiled, &caps, Backend::Simd4) + .unwrap_or_else(|failure| { + panic!("{}", String::from_utf8_lossy(failure.as_bytes())) + }); + let window = bench_alloc::current_thread_delta_since(mark); + let resources = prepared.report.route_control_resources; + drop(prepared); + let bytes = window + .requested_bytes + .checked_sub(window.released_bytes) + .expect("the window frees nothing it did not allocate"); + (bytes, resources) + }; + let (with, resources) = retained(&following); + let (without, none) = retained(&elsewhere); + assert_eq!(none, host_core::RouteControlResources::default()); + assert_eq!(resources.routes, 1); + let follow_cell = (2 * core::mem::size_of::() + + core::mem::size_of::>() + + core::mem::size_of::()) as u64; + assert_eq!(resources.cell_bytes, follow_cell, "a seven-word cell"); + let added = with + .checked_sub(without) + .filter(|bytes| *bytes > 0) + .unwrap_or_else(|| panic!("the lane retains more: {with} against {without}")); + assert!( + resources.total_bytes >= added, + "the charge {} covers the {added} bytes the attach and bind retain", + resources.total_bytes + ); + eprintln!( + "following route charge: charged {}, retained {added}", + resources.total_bytes + ); +} diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 96cc48ee9..bec903f93 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -5209,3 +5209,107 @@ fn re_authoring_keeps_the_hand_offset_and_removal_ends_it_alike_on_both_hosts() ); } } + +/// #1652 gate 1's session on both hosts: the command session at 48 kHz with no console slot and +/// no insert (so no strip state outlives a mute), whose `t1` sends `pre_fader` with +/// `follows_mute` into bus `b`; `t1` gets a mute entry on `both` with steps 0, 1, 0 at 1,000 and +/// 2,500 (`entry`), or is muted statically (`muted`). `control_smoothing` is explicit: the mute +/// length is 480 samples. +fn follow_model(entry: bool, muted: bool) -> SessionModel { + let mut model = model_at(48_000); + model.console.pre_insert.clear(); + model.console.post_insert.clear(); + for track in &mut model.tracks { + track.console.clear(); + track.inserts.effects.clear(); + } + for submix in &mut model.submixes { + submix.console.clear(); + submix.inserts.effects.clear(); + } + model.control_smoothing = Some(session::ControlSmoothing { + mute_ms: 10.0, + fader_ms: 5.0, + pan_ms: 5.0, + }); + let send = model + .routes + .iter() + .find(|route| route.id.as_str() == "t1-b") + .expect("the command session's following send"); + assert!(send.follows_mute); + let t1 = model + .tracks + .iter_mut() + .find(|track| track.id.as_str() == T1.id) + .expect("t1"); + (t1.fader.left_mute, t1.fader.right_mute) = (muted, muted); + if entry { + let step = |start: u64, end: u64, from: f32, to: f32| session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: session::ParameterUnit::Linear, + }; + model.automation.push(session::Automation { + id: id("t1-mute"), + target: address( + Owner::Strip(id(T1.id)), + Section::Builtins, + MUTE, + ParameterChannel::Both, + ), + segments: vec![step(0, 1_000, 0.0, 1.0), step(1_000, 2_500, 1.0, 0.0)], + offset: session::AutomationOffset::default(), + }); + } + model +} + +/// #1652 gate 1 on both hosts (the browser at its native width, as the C ABI). Both hosts render +/// the same bits for the session with `t1`'s mute entry. From the first block boundary after the +/// end of the mute ramp to the unmute, each host's output is bit for bit its plan with `t1` +/// statically muted, fed the same PCM; from the first block boundary after the end of the unmute +/// ramp, bit for bit its plan with no mute. Not vacuous: before the mute the curve's plan differs +/// from the statically muted one. +/// +/// Test value: red if either host's plan attaches no following lane for `t1-b` (its pre-fader +/// audio leaks into `b` while `t1` is muted), or if the two hosts disagree. +#[test] +fn a_following_send_leaks_nothing_of_a_stored_mute_on_both_hosts() { + const BLOCKS: u64 = 32; + let quantum = u64::from(QUANTUM); + let run = |model: &SessionModel| { + let mut browser = Rig::new(model); + let mut c_abi = CAbi::new(model); + (0..BLOCKS) + .map(|block| { + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "block {block}: the two hosts agree"); + b + }) + .collect::>() + }; + let curve = run(&follow_model(true, false)); + let muted = run(&follow_model(false, true)); + let open = run(&follow_model(false, false)); + let first_after = |sample: u64| sample.div_ceil(quantum); + for block in first_after(1_000 + 480)..2_500 / quantum { + assert_eq!( + curve[block as usize], muted[block as usize], + "block {block}: the statically muted plan's bits while the curve mutes" + ); + } + for block in first_after(2_500 + 480)..BLOCKS { + assert_eq!( + curve[block as usize], open[block as usize], + "block {block}: the open plan's bits after the unmute" + ); + } + assert_ne!( + curve[2], muted[2], + "the strip and its send play before the mute" + ); +} From 31f72452161a7aa5ae2aec6933d8d015d287bd0d Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 08:31:51 +0000 Subject: [PATCH 13/23] Keep the graph manifest comment clear of the builtins policy scan (#1652a) The builtins policy forbids the word builtins in graph's manifest; the #1652a comment named the strip compiler crate. Reworded. Co-Authored-By: Claude Opus 5.5 --- crates/graph/Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/graph/Cargo.toml b/crates/graph/Cargo.toml index c2b971fdf..3d9255b82 100644 --- a/crates/graph/Cargo.toml +++ b/crates/graph/Cargo.toml @@ -14,7 +14,8 @@ test-support = ["rack/test-support"] [dependencies] # The route op of a following send evaluates its source strip's stored mute curves with the -# plan's own cursors (#1652 D2): graph cannot reach them through builtins-compiler. +# plan's own cursors (#1652 D2). automation is a leaf crate; graph never depends on the strip +# compiler that also uses it. automation.workspace = true engine.workspace = true effect-contract.workspace = true From 266a2724c1acb7db5b661c2aa76fb826c72e2c41 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 08:46:24 +0000 Subject: [PATCH 14/23] Gate stored mute automation on both hosts and list a driven mute row automated (#1651 T5, R18-2) - R18-2: the control listing marks a mute row automated on each lane a mute entry drives, on both hosts; the row's value stays the hand mute. - Gate 1: a curve mutes at its exact samples over the session mute length and is bit-identical to the static plan from the next block boundary; a seek into a muted span mutes exactly. Quanta 128, 100, 37. - Gate 3: solo, a VCA mute and a hand mute compose with the curve (curve || terms) on both hosts. - Gate 4: a mute entry change rebuilds; hand and VCA mutes give one Mute record each (the terms write); fader edits stay fader records. - Gate 5: control-plane mute_automation_realtime renders the script allocation-free through the shared commit. Co-Authored-By: Claude Opus 5.5 --- .../1651-render-stored-mute-automation.md | 66 ++ crates/capi/src/runtime/mod.rs | 2 + .../capi/src/runtime/mute_automation_tests.rs | 807 ++++++++++++++++++ .../tests/mute_automation_realtime.rs | 343 ++++++++ crates/controls/src/registry.rs | 17 +- crates/controls/src/strip.rs | 26 +- crates/host-core/tests/live_delta.rs | 136 ++- docs/CONTROL_PROTOCOL_REGISTRY.md | 2 +- docs/SESSION_SCHEMA_V1.md | 4 +- .../host-web/tests/stored_mute_automation.rs | 768 +++++++++++++++++ 10 files changed, 2159 insertions(+), 12 deletions(-) create mode 100644 crates/capi/src/runtime/mute_automation_tests.rs create mode 100644 crates/control-plane/tests/mute_automation_realtime.rs create mode 100644 hosts/host-web/tests/stored_mute_automation.rs diff --git a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md index 6e527674b..e9cd21135 100644 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md @@ -568,3 +568,69 @@ PR #1712). shortened when a curve unmute inside that block ends the hold earlier; grid events between the new `E` and the old ramp end are pushed as held (`RememberGain` on a free stage) until the cell's next free event. Needs a VCA write during a mute ramp and a curve unmute inside it. + +## Decision record: T5 (batch 18) + +- **Shipped (root ruling R18-2).** The control list marks a strip's builtin `mute` row + `automated` (`ControlEntry` field 35) on each lane a mute entry drives, on both hosts; the row's + value stays the lane's static (hand) mute. `controls::owner_rows_in` reads the driving entries + from the committed table (`controls::mute_automation`, as the fader reads its own) and passes + them with the fader offsets to `strip::push_rows` as one `strip::Driven`. No row class or table + changes, so the SDK is not regenerated. Docs: `docs/SESSION_SCHEMA_V1.md` (the mute row + paragraph) and `docs/CONTROL_PROTOCOL_REGISTRY.md` (field 35, outside the listed paths: one + clause, mechanical to the ruling). `docs/REALTIME_MEMORY.md` and `host-core/src/live_delta.rs` + were not stale (T3a). One stale doc line in `crates/host-core/tests/live_delta.rs` + (`fader_ride`: "which nothing renders") is fixed. +- **Gates (all pass).** + - Gates 1 and 3 and R18-2, both hosts: one script in two copies (kept equal; a diff of the two + script halves is empty), `crates/capi/src/runtime/mute_automation_tests.rs` (new, one `mod` + line in `runtime/mod.rs`) and `hosts/host-web/tests/stored_mute_automation.rs` (new). Each + host compares its own output with plans of the same host prepared with `t` statically + unmuted and muted, fed the same PCM (both of `t`'s lanes negative in every fourth stretch of + 300 frames, `u` at `-0.0` in others). + - Gate 1: `a_mute_curve_mutes_at_its_exact_samples` (steps at 1,000 and 2,500; every sample + before a step equals the static plan it leaves, the step sample and `step + M - 2` differ, + every sample from the first block boundary at or after `step + M` to the next step equals + the new state's static plan; quanta 128, 100, 37) and + `a_seek_into_a_muted_span_mutes_exactly_from_the_seek` (a plain session seek to 1,600 at the + fifth block: muted exactly from that block, the opening ramps 900 samples later; quanta 128, + 100, 37). + - Gate 3 (and gate 4's browser case): `solo_vca_and_hand_mutes_compose_with_the_curve` (solo + on `u`, a VCA mute and a hand mute of `t`, each set while the curve is open and released + while closed, released while open, or kept; solo replies `monitor`, the VCA and the hand + mute `live`, so a hand mute through the Worker's apply is `live` and the output follows + `curve || terms`). + - R18-2: `the_list_marks_a_mute_row_an_entry_drives_automated` (a `left` and a `both` entry; + `t`'s fader rows and `u`'s mute rows unmarked; a hand mute moves the row's value only). + - Gate 4: `crates/host-core/tests/live_delta.rs::a_mute_curve_takes_its_terms_as_mute_records` + (a `both` and a `left` curve: an entry change is `Err(LiveRebuild::Automation)`; a hand mute + on either lane, a VCA mute of one lane and of both are each one `Mute` record; a VCA fader + move and a static `fader_db` change give `FaderDb` records and no offsets write). + - Gate 5: `crates/control-plane/tests/mute_automation_realtime.rs::stored_mute_automation_renders_allocation_free` + (`eq0` of the nine-track fixture with the curve, through `SessionState::command` and + `PlanState::render`: both steps, a hand mute and a VCA mute set and released live, a plain + session seek to 1,600 (a declared successor) and the opening after it; 44 blocks, each + `allocations == 0 && frees == 0`; a twin without the curve differs inside each mute and equals + at the end). +- **Red checks (temporary mutations, each reverted; red on both hosts).** Terms overwrite the + curve in `LaneFaderAutomation::mute_terms` (gate 3, "released at 2560"); a curve event + overwrites the terms in the driver (gate 3, "kept", at 3,500); a seek `Set` ramps over 480 + (seek gate, at the seek block); the jump ramps over half the mute length (gate 1 and the seek + gate, at `step + M - 2`); the listing flag fader-only (R18-2 gate). An allocation in + `mute_terms` aborts gate 5's binary (the render-locked allocator). Gate 4 was not mutated: its + mutation lives in `crates/host-core/src/prepare.rs` (`fader_records`), which this tranche must + not touch. +- **Departures.** + - Gates 1 and 3 are not one binary that compares the two hosts bit for bit (the batch 18 + amendment's `control_parity.rs` form): a new host-web test file may not call the C ABI's + `unsafe` exports (`realtime_policy`'s unsafe allowlist), and the shared files were kept free + of this tranche. Each host instead holds the same claims against its own static plans, as + the filed gate text has it. + - Gate 1's widths are not repeated here: the host tests run the native backend; Simd8, Simd4 + and the scalar oracle are T3a's `crates/host-core/tests/stored_mute_automation.rs`. +- **Ran.** `cargo test --locked` on `-p controls --all-features` (72), `-p host-core --features + host-core/test-support,host-core/control-provider` (569), `-p capi` (229), `-p host-web + --features host-web/test-support` (211), `-p control-plane --features test-support` (26): all + pass. `check-host-core-policy.sh`, `check-workspace-policy.sh`, `realtime_policy` (161 marked + regions, unchanged), fmt, clippy (the five crates, all targets and features, `-D warnings`) + and the no-feature `cargo check --all-targets`: ok. diff --git a/crates/capi/src/runtime/mod.rs b/crates/capi/src/runtime/mod.rs index 59ab83302..2ca2dbc5e 100644 --- a/crates/capi/src/runtime/mod.rs +++ b/crates/capi/src/runtime/mod.rs @@ -30,6 +30,8 @@ mod live_peak_tests; #[cfg(test)] mod live_tests; #[cfg(test)] +mod mute_automation_tests; +#[cfg(test)] mod supersede_tests; #[cfg(test)] mod tests; diff --git a/crates/capi/src/runtime/mute_automation_tests.rs b/crates/capi/src/runtime/mute_automation_tests.rs new file mode 100644 index 000000000..9c444eadb --- /dev/null +++ b/crates/capi/src/runtime/mute_automation_tests.rs @@ -0,0 +1,807 @@ +//! #1651 T5 on the C ABI: stored mute automation (builtin row 6) through the exported entries +//! (`miso_engine_v1_submit_command`, the source submit, the session seek and the render, through +//! `ffi`'s test wrappers). `hosts/host-web/tests/stored_mute_automation.rs` runs the same script +//! on the browser host; the bank widths (Simd8, Simd4 and the scalar oracle) are `host-core`'s +//! `stored_mute_automation.rs`. +//! +//! The session: tracks `t` and `u`, each on its own source and routed to the output, with no +//! console, no insert and no send; VCA `v` holds `t`. `t`'s mute is driven by a step curve: +//! unmuted, muted over a span whose ends are not grid samples, unmuted again. The session mute +//! length is `mute_ms` 10, `M = 480` at 48 kHz. Every check compares the output with plans +//! prepared with `t` statically unmuted and muted, fed the same PCM. +//! +//! - Gate 1 (exact samples), with a seek case. +//! - Gate 3 (composition): solo, a VCA mute and a hand mute (OQ1 = C) compose with the curve. +//! - Root ruling R18-2: the control list marks a `mute` row an entry drives `automated`. + +use core::ops::Range; + +use super::tests::{SESSION as FIXTURE, boxed_c_children_with_limits, command_c_capacity, limits}; +use protocol::{ + CommandPayload, ControlListRequest, ControlSection, DecodeScratch, + DecodedSuccessResponsePayload, DecodedTypedResponseFrame, EditPath, ExpectedRevision, + ProtocolCodec, RequestId, SessionEdit, SessionRevision, TypedCommandFrame, +}; +use session::{ + Automation, AutomationOffset, AutomationSegment, AutomationShape, ControlAddress, + ControlSmoothing, DualMonoFader, MatrixOrPan, Owner, ParameterChannel, ParameterUnit, Section, + SessionModel, StableId, Vca, canonical_session_json, parse_session_json, +}; + +/// The host the script runs on. +type Host = CAbi; + +/// The C ABI leg at any quantum: a session and plan compiled from the document. +struct CAbi { + session: *mut crate::Session, + plan: *mut crate::Plan, + quantum: u64, + revision: u64, + request: u64, + /// The next block's render sample, and the source generation and frame it is fed from. + at: u64, + generation: u64, + start: u64, +} + +impl Drop for CAbi { + fn drop(&mut self) { + crate::ffi::test_plan_destroy(self.plan); + crate::ffi::test_session_destroy(self.session); + } +} + +impl Leg for CAbi { + fn boot(model: &SessionModel) -> Self { + let document = canonical_session_json(model).expect("canonical session"); + let (session, plan) = boxed_c_children_with_limits( + &document, + crate::CompileLimits { + source_ring_frames: model.quantum_frames * 4, + maximum_vcas: 4, + ..limits() + }, + ); + Self { + session, + plan, + quantum: u64::from(model.quantum_frames), + revision: model.revision, + request: 0, + at: 0, + generation: 1, + start: 0, + } + } + + fn quantum(&self) -> u64 { + self.quantum + } + + fn at(&self) -> u64 { + self.at + } + + fn step(&mut self) -> Vec { + let frames = u32::try_from(self.quantum).expect("quantum"); + for (feed, name) in ["src-t", "src-u"].into_iter().enumerate() { + let [left, right] = chunk(feed as u64, self.start, self.quantum); + let planes = [left.as_ptr(), right.as_ptr()]; + let chunk = crate::SourceChunk { + struct_size: crate::SOURCE_CHUNK_SIZE, + sample_rate_hz: RATE, + generation: self.generation, + start_frame: self.start, + planes: planes.as_ptr(), + plane_count: 2, + frames, + end_of_region: 0, + reserved0: 0, + }; + let mut report = crate::SubmitReport { + struct_size: crate::SUBMIT_REPORT_SIZE, + reserved0: 0, + accepted_frames: 0, + cumulative_written_frames: 0, + active_generation: 0, + }; + assert_eq!( + crate::ffi::test_source_submit(self.session, name.as_bytes(), &chunk, &mut report), + crate::RESULT_OK, + "C ABI source {name}" + ); + assert_eq!(report.accepted_frames, self.quantum); + } + let mut pcm = vec![f32::NAN; 2 * self.quantum as usize]; + let output = crate::PlanarOutput { + struct_size: crate::PLANAR_OUTPUT_SIZE, + channels: 2, + samples: pcm.as_mut_ptr(), + sample_capacity: pcm.len() as u64, + frames, + plane_stride_samples: frames, + reserved: [0; 2], + }; + assert_eq!( + crate::ffi::test_render(self.plan, self.at, &output), + crate::RESULT_OK, + "C ABI render" + ); + self.at += self.quantum; + self.start += self.quantum; + bits(&pcm) + } + + fn seek(&mut self, timeline_frame: u64) { + self.generation += 1; + assert_eq!( + crate::ffi::test_session_seek(self.session, self.generation, timeline_frame, None), + crate::RESULT_OK, + "C ABI session seek" + ); + self.start = timeline_frame; + } + + fn command(&mut self, payload: CommandPayload<'_>, expected: ExpectedRevision) -> Vec { + self.request += 1; + let bytes = frame(self.request, payload, expected); + let (result, length, mut response) = command_c_capacity(self.session, &bytes, 16 << 10); + assert_eq!(result, crate::RESULT_OK, "C ABI command"); + response.truncate(usize::try_from(length).expect("response length")); + // Drain the reliable lane, so a later commit is never backpressured by an unread event. + loop { + let mut event = [0_u8; 4_096]; + let mut out = crate::BytesOut { + struct_size: crate::BYTES_OUT_SIZE, + reserved0: 0, + data: event.as_mut_ptr(), + capacity_bytes: event.len() as u64, + required_bytes: 0, + }; + assert_eq!( + crate::ffi::test_dequeue_event(self.session, crate::EVENT_LANE_RELIABLE, &mut out), + crate::RESULT_OK + ); + if out.required_bytes == 0 { + break; + } + } + response + } + + fn revision(&self) -> u64 { + self.revision + } + + fn set_revision(&mut self, revision: u64) { + self.revision = revision; + } +} + +// --------------------------------------------------------------------------------------------- +// The script. `crates/capi/src/runtime/mute_automation_tests.rs` and +// `hosts/host-web/tests/stored_mute_automation.rs` run this same script, each on its own host +// (`Host`); keep the two copies equal. +// --------------------------------------------------------------------------------------------- + +const RATE: u32 = 48_000; +/// The session mute length: `mute_ms` 10 at 48 kHz. +const M: u64 = 480; +/// Every run's length in samples; the sources are longer. +const END: u64 = 5_000; +const FADER_DB: u32 = 5; +const MUTE: u32 = 6; +/// The own-row ID of a strip's `solo`, a monitor row. +const SOLO: u32 = 2; +/// The own-row ID of a VCA's `mute`. +const VCA_MUTE: u32 = 2; + +/// One host's session as the script drives it: booted on a document, fed one quantum of both +/// sources per block (`src-t` and `src-u`, from [`chunk`]) and rendered. +trait Leg: Sized { + fn boot(model: &SessionModel) -> Self; + fn quantum(&self) -> u64; + /// The render sample of the next block. + fn at(&self) -> u64; + /// Feed and render the next block: the output's bits, left plane then right. + fn step(&mut self) -> Vec; + /// A plain session seek to `timeline_frame`, applied at the next block; later blocks are fed + /// from there under a new generation. + fn seek(&mut self, timeline_frame: u64); + /// One command frame through the host's command entry; the response frame. + fn command(&mut self, payload: CommandPayload<'_>, expected: ExpectedRevision) -> Vec; + /// The committed revision the last reply named. + fn revision(&self) -> u64; + fn set_revision(&mut self, revision: u64); +} + +fn id(text: &str) -> StableId { + StableId::parse(text).expect("stable ID") +} + +/// `t`'s mute curve: open, closed over `closed`, open again. +fn curve(lane: ParameterChannel, closed: Range) -> Automation { + let step = |span: Range, value: f32| AutomationSegment { + shape: AutomationShape::Step, + start_sample: span.start, + end_sample: span.end, + start_value: value, + end_value: value, + unit: ParameterUnit::Linear, + }; + Automation { + id: id("t-mute-curve"), + target: ControlAddress { + owner: Owner::Strip(id("t")), + section: Section::Builtins, + control: MUTE, + lane, + }, + segments: vec![ + step(0..closed.start, 0.0), + step(closed.clone(), 1.0), + step(closed.end..END + 1_000, 0.0), + ], + offset: AutomationOffset::default(), + } +} + +/// The session at quantum `quantum`, from the nine-track fixture: tracks `t` and `u` over +/// `src-t` and `src-u`, each to the output, with no console, no insert, no input filter and no +/// send; VCA `v` holds `t`; `t` statically muted when `muted`, and `entry` in the automation +/// table. +fn model(quantum: u32, entry: Option, muted: bool) -> SessionModel { + let mut model = parse_session_json(FIXTURE).expect("the nine-track fixture"); + model.sample_rate_hz = RATE; + model.quantum_frames = quantum; + model.control_smoothing = Some(ControlSmoothing { + mute_ms: 10.0, + fader_ms: 5.0, + pan_ms: 5.0, + }); + model.console.pre_insert.clear(); + model.console.post_insert.clear(); + model.automation.clear(); + let source = model.sources[0].clone(); + let track = model.tracks[0].clone(); + let route = model + .routes + .iter() + .find(|route| { + matches!(&route.source, session::RouteSource::Track { track_id, .. } + if *track_id == track.id) + }) + .expect("the first track's route") + .clone(); + model.sources.clear(); + model.tracks.clear(); + model.routes.clear(); + for (name, db, pan) in [("t", -2.0, -0.3), ("u", -4.0, 0.4)] { + let mut source = source.clone(); + source.id = id(&format!("src-{name}")); + source.frames = 2 * END; + let mut track = track.clone(); + track.id = id(name); + track.source_id = source.id.clone(); + 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.fader = DualMonoFader { + left_db: db, + right_db: db, + left_mute: muted && name == "t", + right_mute: muted && name == "t", + }; + track.matrix_or_pan = MatrixOrPan::Pan { + left: pan, + right: -pan, + smoothing_samples: 0, + }; + let mut route = route.clone(); + route.id = id(&format!("{name}-main")); + let session::RouteSource::Track { track_id, .. } = &mut route.source else { + panic!("the fixture routes a track") + }; + *track_id = track.id.clone(); + model.sources.push(source); + model.tracks.push(track); + model.routes.push(route); + } + 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("t")], + }); + model.automation.extend(entry); + model +} + +/// Source `feed` (`0` for `t`, `1` for `u`), lane `lane`, source frame `frame`: deterministic and +/// never zero, but for `u`'s stretches of `-0.0`; both of `t`'s lanes are negative in every +/// fourth stretch of 300 frames, so a sign of zero of `t`'s muted lane reaches the output. +fn sample(feed: u64, lane: u64, frame: u64) -> f32 { + if feed == 1 && (frame / 250) % 5 == 3 { + return -0.0; + } + let mut z = (feed + 1) + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + .wrapping_add(lane.wrapping_mul(0xBF58_476D_1CE4_E5B9)) + .wrapping_add(frame.wrapping_mul(0x94D0_49BB_1331_11EB)); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^= z >> 31; + let value = ((z >> 40) as f32 / (1_u64 << 24) as f32 - 0.5).clamp(-0.5, 0.5); + let value = if value == 0.0 { 0.25 } else { value }; + if feed == 0 && (frame / 300) % 4 == 1 { + -value.abs() + } else { + value + } +} + +/// Both lanes of source `feed` from source frame `start`. +fn chunk(feed: u64, start: u64, frames: u64) -> [Vec; 2] { + [0, 1].map(|lane| { + (0..frames) + .map(|offset| sample(feed, lane, start + offset)) + .collect() + }) +} + +fn bits(pcm: &[f32]) -> Vec { + pcm.iter().map(|sample| sample.to_bits()).collect() +} + +/// Encode one command frame. +fn frame(request: u64, payload: CommandPayload<'_>, expected: ExpectedRevision) -> Vec { + let mut bytes = vec![0_u8; 16 << 10]; + let length = ProtocolCodec::default() + .encode_command_frame_into( + &TypedCommandFrame { + request_id: RequestId::new(request).expect("nonzero request"), + expected_revision: expected, + payload, + }, + &mut bytes, + ) + .expect("command frame"); + bytes.truncate(length); + bytes +} + +/// `edits` as one transaction at the committed revision: the path and the revision the reply +/// names. A refusal panics with its diagnostic codes. +fn transaction(leg: &mut L, edits: &[SessionEdit]) -> (EditPath, u64) { + let revision = leg.revision(); + let reply = leg.command( + CommandPayload::SessionTransactionApply(edits), + ExpectedRevision::Exact(SessionRevision(revision)), + ); + let mut fields = [0_u16; 64]; + match ProtocolCodec::default() + .decode_typed_response(&reply, &mut DecodeScratch::new(&mut fields)) + .expect("a transaction response") + { + DecodedTypedResponseFrame::Success { + header, + payload: DecodedSuccessResponsePayload::SessionTransactionApplied(applied), + } => { + leg.set_revision(header.revision.0); + (applied.edit_path, header.revision.0) + } + DecodedTypedResponseFrame::NonOk { payload, .. } => panic!( + "the transaction is refused: {:?}", + payload + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code.to_string()) + .collect::>() + ), + DecodedTypedResponseFrame::Success { .. } => panic!("another success payload"), + } +} + +/// Which static plan a span of render samples must equal. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Static { + Open, + Closed, +} + +/// What the output must be: spans equal to a static plan, and samples that differ from one (where +/// a ramp starts, the first sample that differs from the static plan the lane leaves; or late in a +/// ramp, a sample that has not yet reached the static plan the lane moves to). +#[derive(Default)] +struct Expect { + equal: Vec<(Range, Static)>, + differ: Vec<(u64, Static)>, +} + +/// The first block boundary at or after `sample`. +fn boundary(sample: u64, quantum: u64) -> u64 { + sample.div_ceil(quantum) * quantum +} + +/// Check one block's bits (`at` its first render sample) against the static plans' blocks. +fn check( + label: &str, + expect: &Expect, + at: u64, + quantum: u64, + out: &[u32], + open: &[u32], + closed: &[u32], +) { + let pick = |which: Static| match which { + Static::Open => open, + Static::Closed => closed, + }; + for offset in 0..quantum { + let sample = at + offset; + let index = offset as usize; + let planes = [index, index + quantum as usize]; + for (span, which) in &expect.equal { + if span.contains(&sample) { + let reference = pick(*which); + assert_eq!( + planes.map(|i| out[i]), + planes.map(|i| reference[i]), + "{label}: sample {sample} equals the {which:?} static plan" + ); + } + } + for (start, which) in &expect.differ { + if *start == sample { + let reference = pick(*which); + assert_ne!( + planes.map(|i| out[i]), + planes.map(|i| reference[i]), + "{label}: sample {sample} differs from the {which:?} static plan (a ramp)" + ); + } + } + } +} + +/// Run `leg` and two static plans on the same host (`open`: `t` unmuted, `closed`: `t` muted) +/// to [`END`], fed the same PCM: before the block at render sample `at`, `before(at, leg)` may +/// edit, and may return a session seek's timeline frame (mirrored on the static plans); every +/// block is checked against `expect`. +fn run( + label: &str, + leg: &mut L, + expect: &Expect, + mut before: impl FnMut(u64, &mut L) -> Option, +) { + let quantum = u32::try_from(leg.quantum()).expect("quantum"); + let mut open = L::boot(&model(quantum, None, false)); + let mut closed = L::boot(&model(quantum, None, true)); + while leg.at() < END { + let at = leg.at(); + if let Some(frame) = before(at, leg) { + leg.seek(frame); + open.seek(frame); + closed.seek(frame); + } + let out = leg.step(); + check( + label, + expect, + at, + leg.quantum(), + &out, + &open.step(), + &closed.step(), + ); + } +} + +/// #1651 gate 1. `t`'s curve closes at 1,000 and opens at 2,500 (neither a multiple of the +/// grid's 64). Each ramp starts at its step sample: every earlier sample equals the static plan +/// the lane leaves and the step sample differs from it; the ramp is still moving at `step + M - 2` +/// (the session mute length, not a shorter one); from the first block boundary at or after the +/// ramp's end (`step + M`) every sample equals the static plan of the new state, up to the next +/// step. Quanta 128, 100 and 37. +/// +/// Test value: red if a mute step snaps to a block or to the grid, ramps over another row's length +/// or over none, or ramps where the static plan does not (a muted lane left at a residue or at a +/// zero of another sign). +#[test] +fn a_mute_curve_mutes_at_its_exact_samples() { + let closed = 1_000..2_500; + for quantum in [128_u32, 100, 37] { + let q = u64::from(quantum); + let label = format!("quantum {quantum}"); + let mut leg = Host::boot(&model( + quantum, + Some(curve(ParameterChannel::Both, closed.clone())), + false, + )); + let expect = Expect { + equal: vec![ + (0..closed.start, Static::Open), + (boundary(closed.start + M, q)..closed.end, Static::Closed), + (boundary(closed.end + M, q)..END, Static::Open), + ], + differ: vec![ + (closed.start, Static::Open), + (closed.start + M - 2, Static::Closed), + (closed.end, Static::Closed), + (closed.end + M - 2, Static::Open), + ], + }; + run(&label, &mut leg, &expect, |_, _| None); + } +} + +/// #1651 gate 1, the seek case. Before the fifth block (render sample `R`) a plain session seek +/// lands on timeline 1,600, inside the curve's muted span: from `R` on the lane is muted exactly +/// (`n = 0`: every sample equals the muted static plan, seeked alike), and the curve's opening at +/// timeline 2,500 ramps from render sample `R + 900`. Quanta 128, 100 and 37. +/// +/// Test value: red if a seek into a muted span ramps instead of setting the stage, keeps the state +/// from before the seek, or places the curve's next step off the new timeline. +#[test] +fn a_seek_into_a_muted_span_mutes_exactly_from_the_seek() { + let closed = 1_000..2_500; + let target = 1_600; + for quantum in [128_u32, 100, 37] { + let q = u64::from(quantum); + let seek_at = 4 * q; + let opens_at = seek_at + (closed.end - target); + let label = format!("quantum {quantum}, seek"); + let mut leg = Host::boot(&model( + quantum, + Some(curve(ParameterChannel::Both, closed.clone())), + false, + )); + let expect = Expect { + equal: vec![ + (0..seek_at, Static::Open), + (seek_at..opens_at, Static::Closed), + (boundary(opens_at + M, q)..END, Static::Open), + ], + differ: vec![(opens_at, Static::Closed), (opens_at + M - 2, Static::Open)], + }; + run(&label, &mut leg, &expect, |at, _| { + (at == seek_at).then_some(target) + }); + } +} + +/// A mute term of `t` the curve does not drive, set on or off as one transaction. +#[derive(Clone, Copy, Debug)] +enum Term { + /// Solo on `u` (a monitor row: `t` is muted by solo). + Solo, + /// VCA `v`'s mute. + Vca, + /// `t`'s own (hand) mute, through `SET_CONTROL` (OQ1 = C). + Hand, +} + +impl Term { + fn edit(self, on: bool) -> SessionEdit { + let (owner, section, control) = match self { + Self::Solo => (Owner::Strip(id("u")), Section::Own, SOLO), + Self::Vca => (Owner::Vca(id("v")), Section::Own, VCA_MUTE), + Self::Hand => (Owner::Strip(id("t")), Section::Builtins, MUTE), + }; + SessionEdit::SetControl { + expected_effect: None, + address: ControlAddress { + owner, + section, + control, + lane: ParameterChannel::Both, + }, + value: f32::from(u8::from(on)), + ramp_samples: None, + } + } + + fn path(self) -> EditPath { + match self { + Self::Solo => EditPath::Monitor, + Self::Vca | Self::Hand => EditPath::Live, + } + } +} + +/// #1651 gate 3 and gate 4's live case. `t`'s curve closes at 2,000 and opens at 3,500; quantum +/// 128. Each of solo on `u`, a VCA mute of `t` and a hand mute of `t` (OQ1 = C): +/// +/// - set at 256 while the curve is open, it mutes `t` (the muted static plan from the ramp's end); +/// - released at 2,560, while the curve is closed, `t` stays muted, and the curve's opening at +/// 3,500 ramps the lane open; +/// - released at 1,024, while the curve is open, the lane ramps open at 1,024, then the curve +/// closes it at 2,000 and opens it at 3,500; +/// - never released, `t` stays muted through the curve's opening. +/// +/// Every edit commits with its own path: solo `monitor`, the VCA and the hand mute `live` (on the +/// browser, through the Worker's apply). +/// +/// Test value: red if solo, a VCA mute or a hand mute overwrites the curve instead of composing +/// with it (a release while closed would open the lane), if a curve step overwrites the term (the +/// opening at 3,500 would unmute a lane the term holds), or if a term on an automated lane is +/// dropped (`model_only`). +#[test] +fn solo_vca_and_hand_mutes_compose_with_the_curve() { + const QUANTUM: u32 = 128; + let q = u64::from(QUANTUM); + let closed = 2_000..3_500; + let on_at = 2 * q; + for term in [Term::Solo, Term::Vca, Term::Hand] { + for release in [Some(20 * q), Some(8 * q), None] { + let label = format!("{term:?}, released at {release:?}"); + let mut leg = Host::boot(&model( + QUANTUM, + Some(curve(ParameterChannel::Both, closed.clone())), + false, + )); + let mut expect = Expect { + equal: vec![(0..on_at, Static::Open)], + differ: vec![(on_at, Static::Open)], + }; + let settled = boundary(on_at + M, q); + match release { + // Held through the curve's opening: closed to the end. + None => expect.equal.push((settled..END, Static::Closed)), + // Released while the curve is closed: closed until the curve opens. + Some(at) if closed.contains(&at) => { + expect.equal.push((settled..closed.end, Static::Closed)); + expect.differ.push((closed.end, Static::Closed)); + expect + .equal + .push((boundary(closed.end + M, q)..END, Static::Open)); + } + // Released while the curve is open: open again, then the curve's own steps. + Some(at) => { + expect.equal.push((settled..at, Static::Closed)); + expect.differ.push((at, Static::Closed)); + expect + .equal + .push((boundary(at + M, q)..closed.start, Static::Open)); + expect.differ.push((closed.start, Static::Open)); + expect + .equal + .push((boundary(closed.start + M, q)..closed.end, Static::Closed)); + expect.differ.push((closed.end, Static::Closed)); + expect + .equal + .push((boundary(closed.end + M, q)..END, Static::Open)); + } + } + let mut revision = leg.revision(); + run(&label, &mut leg, &expect, |at, leg| { + let on = if at == on_at { + true + } else if Some(at) == release { + false + } else { + return None; + }; + // Solo is a monitor write: the revision does not advance (C8). + if term.path() == EditPath::Live { + revision += 1; + } + assert_eq!( + transaction(leg, &[term.edit(on)]), + (term.path(), revision), + "{label}: the term at {at} commits with its path" + ); + None + }); + } + } +} + +/// One `mute` or `fader_db` row of strip `owner` as the host lists it: its lane, the bits of its +/// value and its `automated` flag. +type Row = (Option, u32, bool); + +/// The builtin rows `control` of strip `owner`, paged through `CONTROL_LIST_GET`. +fn listed(leg: &mut L, owner: &str, control: u32) -> 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 = leg.command( + 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 entry.address.owner_id.as_deref() == Some(owner) + && entry.address.section == ControlSection::Builtins + && entry.address.control == Some(control) => + { + Some((entry.address.lane, row.value.to_bits(), row.automated)) + } + _ => None, + })); + if page.eof { + return rows; + } + epoch = page.structure_epoch; + after = page.last_handle; + } +} + +/// Root ruling R18-2 (#1650 D4 for the mute row). A mute entry on `t`'s left lane marks `t`'s +/// left `mute` row `automated` and not its right one; an entry on `both` marks both. The row's +/// value stays the static (hand) mute, which a hand mute on the automated lane moves while the +/// row stays `automated`. `t`'s `fader_db` rows and `u`'s `mute` rows are not marked. +/// +/// Test value: red if the flag stays fader-only (a client would offer a plain toggle on a lane the +/// curve drives), if it marks a lane no entry drives, or if the row lists the curve's value instead +/// of the static mute. +#[test] +fn the_list_marks_a_mute_row_an_entry_drives_automated() { + use protocol::ParameterChannel::{Left, Right}; + let closed = 1_000..2_500; + for (lane, expected) in [ + (ParameterChannel::Left, [true, false]), + (ParameterChannel::Both, [true, true]), + ] { + let label = format!("an entry on {lane:?}"); + let mut leg = Host::boot(&model(128, Some(curve(lane, closed.clone())), false)); + let unmuted = 0.0_f32.to_bits(); + assert_eq!( + listed(&mut leg, "t", MUTE), + vec![ + (Some(Left), unmuted, expected[0]), + (Some(Right), unmuted, expected[1]), + ], + "{label}: t's mute rows" + ); + assert!( + listed(&mut leg, "t", FADER_DB).iter().all(|row| !row.2), + "{label}: t's fader rows are not automated" + ); + assert!( + listed(&mut leg, "u", MUTE).iter().all(|row| !row.2), + "{label}: u's mute rows are not automated" + ); + let mut hand = Term::Hand.edit(true); + if let SessionEdit::SetControl { address, .. } = &mut hand { + address.lane = ParameterChannel::Left; + } + assert_eq!( + transaction(&mut leg, &[hand]).0, + EditPath::Live, + "{label}: a hand mute is live" + ); + assert_eq!( + listed(&mut leg, "t", MUTE), + vec![ + (Some(Left), 1.0_f32.to_bits(), expected[0]), + (Some(Right), unmuted, expected[1]), + ], + "{label}: the hand mute is the left row's value" + ); + } +} diff --git a/crates/control-plane/tests/mute_automation_realtime.rs b/crates/control-plane/tests/mute_automation_realtime.rs new file mode 100644 index 000000000..2f8dc03d5 --- /dev/null +++ b/crates/control-plane/tests/mute_automation_realtime.rs @@ -0,0 +1,343 @@ +//! #1651 gate 5: stored mute automation (builtin row 6) renders 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`'s mute is driven by a step curve on `both` lanes (unmuted, muted +//! from sample 1,000 to 2,500, unmuted; neither a grid sample), and the run crosses both steps, +//! takes the mute terms the curve does not drive (a hand mute of `eq0`, OQ1 = C, and a VCA mute of +//! it, each set and released live), and ends with a plain session seek into the muted span and +//! the curve's opening after it. Every render call after the warm-up allocates and frees nothing, +//! measured with the audited allocator's thread-scoped counters: the blocks of each curve step +//! and its ramp, of each terms write, and of the seek's swap and landing. + +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 VCA `v` holding `eq0` and, with `curve`, `eq0`'s mute driven by a step curve +/// on both lanes, closed over `1_000..2_500`. +fn document(curve: bool) -> (String, u64) { + let mut model = session::parse_session_json(SESSION).expect("fixture"); + let frames = model.sources[0].frames; + let step = |start_sample, end_sample, value| AutomationSegment { + shape: AutomationShape::Step, + start_sample, + end_sample, + start_value: value, + end_value: value, + unit: ParameterUnit::Linear, + }; + model.automation.extend(curve.then(|| Automation { + id: id("eq0-mute-curve"), + target: ControlAddress { + owner: Owner::Strip(id("eq0")), + section: Section::Builtins, + control: 6, + lane: ParameterChannel::Both, + }, + segments: vec![ + step(0, 1_000, 0.0), + step(1_000, 2_500, 1.0), + step(2_500, frames, 0.0), + ], + 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")], + }); + ( + session::canonical_session_json(&model).expect("canonical"), + model.revision, + ) +} + +struct Rig { + session: SessionState, + plan: PlanState, + revision: u64, + request: u64, + /// The next block's render sample, and the source generation and frame it is fed from. + at: u64, + generation: u64, + start: u64, + /// The last block's samples, left plane then right. + last: Vec, +} + +impl Rig { + /// A rig on [`document`], booted. + fn new(curve: bool) -> Self { + let (document, revision) = document(curve); + let CompiledChildren { session, plan } = + compile_children::(&document, limits(), Box::new(Preparer)) + .expect("boot compile"); + Self { + session, + plan, + revision, + request: 1, + at: 0, + generation: 1, + start: 0, + last: Vec::new(), + } + } + + /// Feed and render one block; the allocator events of the render call alone. + fn block(&mut self) -> bench_support::alloc::Counters { + let start = self.start; + // Negative pairs in every fourth stretch of 300 frames. + let plane: Vec = (0..QUANTUM as u64) + .map(|frame| { + let value = ((start + frame) % 97) as f32 / 97.0 - 0.5 + 1.0e-3; + if ((start + frame) / 300) % 4 == 1 { + -value.abs() + } else { + value + } + }) + .collect(); + self.session + .submit( + SOURCE, + SourceSubmission { + generation: self.generation, + 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(self.at, output).expect("a rendered block"); + let used = bench_support::alloc::current_thread_delta_since(mark); + self.last = samples.iter().map(|sample| sample.to_bits()).collect(); + self.at += QUANTUM as u64; + self.start += QUANTUM as u64; + used + } + + /// Commit `value` at `address` live (the session's ramp). + fn set(&mut self, address: ControlAddress, value: f32) { + self.request += 1; + let edits = [SessionEdit::SetControl { + expected_effect: None, + address, + value, + ramp_samples: None, + }]; + 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 edit 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 edit is refused: {payload:?}") + } + DecodedTypedResponseFrame::Success { .. } => panic!("another success payload"), + }; + assert_eq!(path, EditPath::Live, "a mute term is live"); + self.revision += 1; + self.drain(); + } + + /// The commit's reliable events, read as a host reads them. + fn drain(&mut self) { + while self + .session + .dequeue_event(EventLane::Reliable, 65_536) + .expect("an event") + .is_some() + {} + } + + /// A plain session seek to `timeline` (declared: a successor at the next block). + fn seek(&mut self, timeline: u64) { + self.generation += 1; + self.session + .session_seek(self.generation, timeline, None) + .expect("a session seek"); + self.drain(); + self.start = timeline; + } +} + +/// Gate 5. `eq0`'s curve closes at 1,000 and opens at 2,500 (blocks 7 and 19 at quantum 128); +/// a hand mute is set at block 4 (while the curve is open) and released at block 10 (closed); a +/// VCA mute is set at block 12 and released at block 22 (after the opening); a plain session seek +/// to timeline 1,600 lands at block 30, inside the muted span, and the curve opens again 900 +/// samples later. Every render call after the four warm-up blocks allocates and frees nothing. A +/// twin with no curve and no edit, fed and seeked alike, differs inside each mute and equals the +/// rig at the end (so the script is heard). +/// +/// Test value: red if the mute cursor's events, the composition of `curve || terms`, the drain of +/// a terms write or the seek's landing on a mute cell allocates or frees on render. +#[test] +fn stored_mute_automation_renders_allocation_free() { + bench_support::alloc::assert_installed(); + let mut rig = Rig::new(true); + // The same session with no curve and no edit, fed and seeked alike: the script is heard. + let mut plain = Rig::new(false); + for _ in 0..4 { + rig.block(); + plain.block(); + } + let hand = ControlAddress { + owner: Owner::Strip(id("eq0")), + section: Section::Builtins, + control: 6, + lane: ParameterChannel::Both, + }; + let vca = ControlAddress { + owner: Owner::Vca(id("v")), + section: Section::Own, + control: 2, + lane: ParameterChannel::Both, + }; + for block in 4..48_u64 { + match block { + 4 => rig.set(hand.clone(), 1.0), + 10 => rig.set(hand.clone(), 0.0), + 12 => rig.set(vca.clone(), 1.0), + 22 => rig.set(vca.clone(), 0.0), + 30 => { + rig.seek(1_600); + plain.seek(1_600); + } + _ => {} + } + let used = rig.block(); + plain.block(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "block {block} allocated or freed" + ); + match block { + // The curve's muted span, the hand mute and the VCA mute are heard. + 8 | 16 | 21 | 31 => assert_ne!(rig.last, plain.last, "block {block} mutes eq0"), + // Open again, with no term: the session without the curve. + 47 => assert_eq!(rig.last, plain.last, "block {block} is open"), + _ => {} + } + } +} diff --git a/crates/controls/src/registry.rs b/crates/controls/src/registry.rs index e9a564619..c8a9160e5 100644 --- a/crates/controls/src/registry.rs +++ b/crates/controls/src/registry.rs @@ -23,8 +23,10 @@ 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. + /// Whether a rendered stored automation drives the row's lane (#1650 D4): the `fader_db` row + /// and, since #1651 (root ruling R18-2), the `mute` row. On such a lane the `fader_db` row's + /// value is the driving entry's hand-move offset, not the static fader; the `mute` row's value + /// stays the lane's static (hand) mute, which render ORs with the curve (OQ1 = C). pub automated: bool, } @@ -218,12 +220,19 @@ 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)); + let driven = strip::Driven { + offsets: builtin_rows::fader_offsets(view.fader_automation(strip.id)), + // Which entry drives a mute lane is read from the committed table, as for the + // fader (a transaction of values leaves the table's entries and targets as + // committed). + muted: crate::automation::mute_automation(model, strip.id) + .map(|entry| entry.is_some()), + }; strip::push_rows( out, &model.console, &strip, - offsets, + driven, &effective.strip(index), overlay, sample_rate, diff --git a/crates/controls/src/strip.rs b/crates/controls/src/strip.rs index 20b41f1cf..99a9552b5 100644 --- a/crates/controls/src/strip.rs +++ b/crates/controls/src/strip.rs @@ -283,17 +283,29 @@ pub(crate) fn strip_mut<'a>(model: &'a mut SessionModel, id: &StableId) -> Optio }) } +/// The builtin lanes of one strip that a rendered stored automation drives, as the listing marks +/// them `automated` (#1650 D4). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Driven { + /// The strip's [`builtin_rows::FaderOffsets`]: an automated fader lane lists its entry's + /// hand-move offset as the `fader_db` value (#1650 D3). + pub(crate) offsets: builtin_rows::FaderOffsets, + /// Per lane (`[left, right]`), whether a mute entry drives the lane: its `mute` row keeps the + /// static (hand) mute as its value, which render ORs with the curve (#1651, root ruling + /// R18-2). + pub(crate) muted: [bool; 2], +} + /// 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). +/// `driven` names the lanes the strip's rendered stored automation drives ([`Driven`]). pub(crate) fn push_rows( out: &mut Vec, console: &Console, strip: &StripRef<'_>, - offsets: builtin_rows::FaderOffsets, + driven: Driven, effective: &EffectiveStrip, overlay: &MonitorOverlay, sample_rate: u32, @@ -308,8 +320,12 @@ pub(crate) fn push_rows( &Section::Builtins, row.id, &descriptor, - |side| builtin_rows::read(strip, row, side, offsets), - |side| row.id == builtin_rows::FADER_DB && side.pick(offsets[0], offsets[1]).is_some(), + |side| builtin_rows::read(strip, row, side, driven.offsets), + |side| match row.id { + builtin_rows::FADER_DB => side.pick(driven.offsets[0], driven.offsets[1]).is_some(), + builtin_rows::MUTE => side.pick(driven.muted[0], driven.muted[1]), + _ => false, + }, ); } // Chain order: the pre-insert slots, the inserts, then the post-insert slots, each in declared diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 1ab2cb5de..615c42a21 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -1099,7 +1099,7 @@ fn structural_edits_need_a_rebuild() { } } -/// A fader ride on the first track's builtin strip: stored automation, which nothing renders. +/// A fader ride on the first track's builtin strip (row 5, a rendered row since #1647). fn fader_ride(model: &SessionModel) -> Automation { Automation { id: id("fader-ride"), @@ -5021,3 +5021,137 @@ fn the_rows_that_read_a_jump_length_are_the_ramped_rows() { .collect(); assert_eq!(vca, [LiveRampRow::VcaOffset, LiveRampRow::VcaMute]); } + +/// A mute curve on the first track (builtin row 6) on `lane`: unmuted, then muted from 1,000 to +/// 2,500, then unmuted (#1651). +fn mute_curve(model: &SessionModel, lane: ParameterChannel) -> Automation { + let step = |start_sample, end_sample, value| AutomationSegment { + shape: AutomationShape::Step, + start_sample, + end_sample, + start_value: value, + end_value: value, + unit: ParameterUnit::Linear, + }; + Automation { + id: id("mute-curve"), + target: ControlAddress { + owner: Owner::Strip(model.tracks[0].id.clone()), + section: Section::Builtins, + control: 6, + lane, + }, + segments: vec![ + step(0, 1_000, 0.0), + step(1_000, 2_500, 1.0), + step(2_500, 4_000, 0.0), + ], + offset: session::AutomationOffset::default(), + } +} + +/// #1651 gate 4 (as amended for OQ1 = C and the batch 18 spec check). The first track's mute is +/// driven by a curve on `lane`; the track is a member of `vca`. +/// +/// - A mute entry change rebuilds (`Err(LiveRebuild::Automation)`). +/// - A hand mute on an automated lane is a live `terms` write: the lane's `Mute` record, which +/// render ORs with the curve. A hand mute on the other, non-automated lane is its `Mute` record. +/// - A VCA mute of the track gives one `Mute` record (the terms write). +/// - The mute entry reaches no fader-only reader: a VCA fader move and a static `fader_db` change +/// on the track give `FaderDb` records and no offsets write (no hand offset, no `S`). +/// +/// Test value: red if the mute row stays masked (a curve edit acked and never heard), if a hand +/// mute or a VCA mute on an automated lane is dropped or turned `model_only` (it would no longer +/// close an open curve), or if a fader-only reader sees the mute entry (the fader would take an +/// offsets write instead of its record). +#[test] +fn a_mute_curve_takes_its_terms_as_mute_records() { + use BuiltinLaneSelector::{Both, Left, Right}; + let session_ramps = LiveRamps { + fader_samples: 100, + mute_samples: 300, + pan_samples: 200, + link_samples: 0, + }; + for (lane, label) in [ + (ParameterChannel::Both, "a curve on both lanes"), + (ParameterChannel::Left, "a curve on the left lane"), + ] { + let current = normalized(&edited(&with_vca(0.0, 0.0, [false; 2]), |model| { + let curve = mute_curve(model, lane); + model.automation.push(curve); + })); + let first = track_id(¤t, 0); + let run = |edit: &dyn Fn(&mut SessionModel)| { + let next = edited(¤t, edit); + classify_live_delta(¤t, &next, session_ramps, &EditRamps::new()) + .map(|delta| level(&delta)) + }; + assert_eq!( + run(&|next| next.automation[0].segments[1].start_sample = 1_100), + Err(LiveRebuild::Automation), + "{label}: a mute entry change rebuilds" + ); + assert_eq!( + run(&|next| next.tracks[0].fader.left_mute = true), + Ok(vec![( + first.clone(), + vec![mute(Left, true, 300)], + [None; 2] + )]), + "{label}: a hand mute on the automated left lane is its Mute record (the terms write)" + ); + assert_eq!( + run(&|next| next.tracks[0].fader.right_mute = true), + Ok(vec![( + first.clone(), + vec![mute(Right, true, 300)], + [None; 2] + )]), + "{label}: a hand mute on the right lane is its Mute record" + ); + assert_eq!( + run(&|next| next.vcas[0].fader.left_mute = true), + Ok(vec![( + first.clone(), + vec![mute(Left, true, 300)], + [None; 2] + )]), + "{label}: a VCA mute of the track is one Mute record" + ); + assert_eq!( + run(&|next| { + next.vcas[0].fader.left_mute = true; + next.vcas[0].fader.right_mute = true; + }), + Ok(vec![( + first.clone(), + vec![mute(Both, true, 300)], + [None; 2] + )]), + "{label}: a VCA mute of both lanes is one Mute record" + ); + // The mute entry reaches no fader-only reader (#1651 T2). + assert_eq!( + run(&|next| { + next.vcas[0].fader.left_db = -6.0; + next.vcas[0].fader.right_db = -6.0; + }), + Ok(vec![( + first.clone(), + vec![fader_db(Both, -6.0, 100)], + [None; 2] + )]), + "{label}: a VCA fader move is the member's FaderDb record, not an offsets write" + ); + assert_eq!( + run(&|next| next.tracks[0].fader.left_db = -6.0), + Ok(vec![( + first.clone(), + vec![fader_db(Left, -6.0, 100)], + [None; 2] + )]), + "{label}: a static fader change is its FaderDb record, not an offsets write" + ); + } +} diff --git a/docs/CONTROL_PROTOCOL_REGISTRY.md b/docs/CONTROL_PROTOCOL_REGISTRY.md index 62d0c6d49..06142ac27 100644 --- a/docs/CONTROL_PROTOCOL_REGISTRY.md +++ b/docs/CONTROL_PROTOCOL_REGISTRY.md @@ -54,7 +54,7 @@ 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`, `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`. +**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; on a strip's builtin `mute` row, #1651 R18-2, `value` stays the lane's static (hand) mute, which render ORs with the curve). 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 is automated only if it is automatable; `automated` is a descriptor field, so an automation edit that changes it advances the structure epoch. diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index bdbbc9bef..94545ac21 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -302,7 +302,9 @@ values `0` and `1`) is compiled into the same table after every fader entry (#16 and unmutes at each step's exact sample over the session's mute ramp length (`control_smoothing.mute_ms`), and the mute the stage renders is the curve's OR the lane's other mute terms (its own static mute, any reaching VCA's mute, and solo), so a hand mute closes an open -curve and a hand unmute leaves a closed curve closed. Only a change of that OR moves the lane. A +curve and a hand unmute leaves a closed curve closed. Only a change of that OR moves the lane. The +control list marks a lane's `mute` row `automated` (`ControlEntry` field 35) while a mute entry +drives the lane (#1651, root ruling R18-2); the row's value stays the lane's static (hand) mute. A session seek sets it exactly where the seek changes it, and a ramp toward a state the seek leaves unchanged finishes. 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 diff --git a/hosts/host-web/tests/stored_mute_automation.rs b/hosts/host-web/tests/stored_mute_automation.rs new file mode 100644 index 000000000..068af1b4c --- /dev/null +++ b/hosts/host-web/tests/stored_mute_automation.rs @@ -0,0 +1,768 @@ +//! #1651 T5 on the browser host: stored mute automation (builtin row 6) through +//! `AudioWorkletEngineHost`, every edit a command frame through the entry the Worker's control +//! plane serves (#1382, #1562a D1). `crates/capi/src/runtime/mute_automation_tests.rs` runs the +//! same script on the C ABI; the bank widths (Simd8, Simd4 and the scalar oracle) are +//! `host-core`'s `stored_mute_automation.rs`. +//! +//! The session: tracks `t` and `u`, each on its own source and routed to the output, with no +//! console, no insert and no send; VCA `v` holds `t`. `t`'s mute is driven by a step curve: +//! unmuted, muted over a span whose ends are not grid samples, unmuted again. The session mute +//! length is `mute_ms` 10, `M = 480` at 48 kHz. Every check compares the output with plans +//! prepared with `t` statically unmuted and muted, fed the same PCM. +//! +//! - Gate 1 (exact samples), with a seek case. +//! - Gate 3 (composition): solo, a VCA mute and a hand mute (OQ1 = C) compose with the curve; +//! gate 4's browser case: a hand mute through the Worker's apply replies `live` and the output +//! follows `curve || terms`. +//! - Root ruling R18-2: the control list marks a `mute` row an entry drives `automated`. + +use core::ops::Range; + +use host_web::{AudioWorkletEngineHost, RESULT_OK, WebBootOptions}; +use protocol::{ + CommandPayload, ControlListRequest, ControlSection, DecodeScratch, + DecodedSuccessResponsePayload, DecodedTypedResponseFrame, EditPath, ExpectedRevision, + ProtocolCodec, RequestId, SessionEdit, SessionRevision, TypedCommandFrame, +}; +use session::{ + Automation, AutomationOffset, AutomationSegment, AutomationShape, ControlAddress, + ControlSmoothing, DualMonoFader, MatrixOrPan, Owner, ParameterChannel, ParameterUnit, Section, + SessionModel, StableId, Vca, canonical_session_json, parse_session_json, +}; + +mod common; + +const FIXTURE: &str = include_str!("../../../fixtures/session/v1/parametric-eq-nine-track.json"); + +/// The host the script runs on. +type Host = Browser; + +/// The browser leg at any quantum: the host booted on the document. +struct Browser { + host: AudioWorkletEngineHost, + quantum: u64, + revision: u64, + request: u64, + /// The next block's render sample, and the source generation and frame it is fed from. + at: u64, + generation: u64, + start: u64, +} + +impl Leg for Browser { + fn boot(model: &SessionModel) -> Self { + let document = canonical_session_json(model).expect("canonical session"); + let options = WebBootOptions { + require_sample_rate_hz: RATE, + require_quantum_frames: model.quantum_frames, + source_ring_frames: model.quantum_frames * 4, + ..WebBootOptions::explicit_defaults() + }; + let host = AudioWorkletEngineHost::boot(document.as_bytes(), options) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.diagnostic()))); + Self { + revision: host.committed_revision(), + host, + quantum: u64::from(model.quantum_frames), + request: 0, + at: 0, + generation: 1, + start: 0, + } + } + + fn quantum(&self) -> u64 { + self.quantum + } + + fn at(&self) -> u64 { + self.at + } + + fn step(&mut self) -> Vec { + let frames = u32::try_from(self.quantum).expect("quantum"); + for (feed, name) in ["src-t", "src-u"].into_iter().enumerate() { + let [left, right] = chunk(feed as u64, self.start, self.quantum); + assert_eq!( + self.host.submit_source( + name.as_bytes(), + self.generation, + self.start, + RATE, + &[&left, &right], + frames, + false, + ), + RESULT_OK, + "browser source {name}" + ); + } + assert_eq!(self.host.render_next(), RESULT_OK, "browser render"); + self.at += self.quantum; + self.start += self.quantum; + bits(self.host.output_pcm().expect("browser output")) + } + + fn seek(&mut self, timeline_frame: u64) { + self.generation += 1; + assert_eq!( + self.host + .session_seek(None, self.generation, timeline_frame, None), + RESULT_OK, + "browser session seek" + ); + self.start = timeline_frame; + } + + fn command(&mut self, payload: CommandPayload<'_>, expected: ExpectedRevision) -> Vec { + self.request += 1; + let bytes = frame(self.request, payload, expected); + let length = u32::try_from(bytes.len()).expect("frame length"); + self.host + .control_request_mut(length) + .expect("the control request staging")[..bytes.len()] + .copy_from_slice(&bytes); + let result = self.host.control_command(length, None); + let response = self.host.control_response().to_vec(); + assert_eq!(result, RESULT_OK, "{}", String::from_utf8_lossy(&response)); + // A commit queues its reliable events; the control half's drain reads them (#1570b D4). + common::drain_events(&mut self.host); + response + } + + fn revision(&self) -> u64 { + self.revision + } + + fn set_revision(&mut self, revision: u64) { + self.revision = revision; + } +} + +// --------------------------------------------------------------------------------------------- +// The script. `crates/capi/src/runtime/mute_automation_tests.rs` and +// `hosts/host-web/tests/stored_mute_automation.rs` run this same script, each on its own host +// (`Host`); keep the two copies equal. +// --------------------------------------------------------------------------------------------- + +const RATE: u32 = 48_000; +/// The session mute length: `mute_ms` 10 at 48 kHz. +const M: u64 = 480; +/// Every run's length in samples; the sources are longer. +const END: u64 = 5_000; +const FADER_DB: u32 = 5; +const MUTE: u32 = 6; +/// The own-row ID of a strip's `solo`, a monitor row. +const SOLO: u32 = 2; +/// The own-row ID of a VCA's `mute`. +const VCA_MUTE: u32 = 2; + +/// One host's session as the script drives it: booted on a document, fed one quantum of both +/// sources per block (`src-t` and `src-u`, from [`chunk`]) and rendered. +trait Leg: Sized { + fn boot(model: &SessionModel) -> Self; + fn quantum(&self) -> u64; + /// The render sample of the next block. + fn at(&self) -> u64; + /// Feed and render the next block: the output's bits, left plane then right. + fn step(&mut self) -> Vec; + /// A plain session seek to `timeline_frame`, applied at the next block; later blocks are fed + /// from there under a new generation. + fn seek(&mut self, timeline_frame: u64); + /// One command frame through the host's command entry; the response frame. + fn command(&mut self, payload: CommandPayload<'_>, expected: ExpectedRevision) -> Vec; + /// The committed revision the last reply named. + fn revision(&self) -> u64; + fn set_revision(&mut self, revision: u64); +} + +fn id(text: &str) -> StableId { + StableId::parse(text).expect("stable ID") +} + +/// `t`'s mute curve: open, closed over `closed`, open again. +fn curve(lane: ParameterChannel, closed: Range) -> Automation { + let step = |span: Range, value: f32| AutomationSegment { + shape: AutomationShape::Step, + start_sample: span.start, + end_sample: span.end, + start_value: value, + end_value: value, + unit: ParameterUnit::Linear, + }; + Automation { + id: id("t-mute-curve"), + target: ControlAddress { + owner: Owner::Strip(id("t")), + section: Section::Builtins, + control: MUTE, + lane, + }, + segments: vec![ + step(0..closed.start, 0.0), + step(closed.clone(), 1.0), + step(closed.end..END + 1_000, 0.0), + ], + offset: AutomationOffset::default(), + } +} + +/// The session at quantum `quantum`, from the nine-track fixture: tracks `t` and `u` over +/// `src-t` and `src-u`, each to the output, with no console, no insert, no input filter and no +/// send; VCA `v` holds `t`; `t` statically muted when `muted`, and `entry` in the automation +/// table. +fn model(quantum: u32, entry: Option, muted: bool) -> SessionModel { + let mut model = parse_session_json(FIXTURE).expect("the nine-track fixture"); + model.sample_rate_hz = RATE; + model.quantum_frames = quantum; + model.control_smoothing = Some(ControlSmoothing { + mute_ms: 10.0, + fader_ms: 5.0, + pan_ms: 5.0, + }); + model.console.pre_insert.clear(); + model.console.post_insert.clear(); + model.automation.clear(); + let source = model.sources[0].clone(); + let track = model.tracks[0].clone(); + let route = model + .routes + .iter() + .find(|route| { + matches!(&route.source, session::RouteSource::Track { track_id, .. } + if *track_id == track.id) + }) + .expect("the first track's route") + .clone(); + model.sources.clear(); + model.tracks.clear(); + model.routes.clear(); + for (name, db, pan) in [("t", -2.0, -0.3), ("u", -4.0, 0.4)] { + let mut source = source.clone(); + source.id = id(&format!("src-{name}")); + source.frames = 2 * END; + let mut track = track.clone(); + track.id = id(name); + track.source_id = source.id.clone(); + 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.fader = DualMonoFader { + left_db: db, + right_db: db, + left_mute: muted && name == "t", + right_mute: muted && name == "t", + }; + track.matrix_or_pan = MatrixOrPan::Pan { + left: pan, + right: -pan, + smoothing_samples: 0, + }; + let mut route = route.clone(); + route.id = id(&format!("{name}-main")); + let session::RouteSource::Track { track_id, .. } = &mut route.source else { + panic!("the fixture routes a track") + }; + *track_id = track.id.clone(); + model.sources.push(source); + model.tracks.push(track); + model.routes.push(route); + } + 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("t")], + }); + model.automation.extend(entry); + model +} + +/// Source `feed` (`0` for `t`, `1` for `u`), lane `lane`, source frame `frame`: deterministic and +/// never zero, but for `u`'s stretches of `-0.0`; both of `t`'s lanes are negative in every +/// fourth stretch of 300 frames, so a sign of zero of `t`'s muted lane reaches the output. +fn sample(feed: u64, lane: u64, frame: u64) -> f32 { + if feed == 1 && (frame / 250) % 5 == 3 { + return -0.0; + } + let mut z = (feed + 1) + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + .wrapping_add(lane.wrapping_mul(0xBF58_476D_1CE4_E5B9)) + .wrapping_add(frame.wrapping_mul(0x94D0_49BB_1331_11EB)); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^= z >> 31; + let value = ((z >> 40) as f32 / (1_u64 << 24) as f32 - 0.5).clamp(-0.5, 0.5); + let value = if value == 0.0 { 0.25 } else { value }; + if feed == 0 && (frame / 300) % 4 == 1 { + -value.abs() + } else { + value + } +} + +/// Both lanes of source `feed` from source frame `start`. +fn chunk(feed: u64, start: u64, frames: u64) -> [Vec; 2] { + [0, 1].map(|lane| { + (0..frames) + .map(|offset| sample(feed, lane, start + offset)) + .collect() + }) +} + +fn bits(pcm: &[f32]) -> Vec { + pcm.iter().map(|sample| sample.to_bits()).collect() +} + +/// Encode one command frame. +fn frame(request: u64, payload: CommandPayload<'_>, expected: ExpectedRevision) -> Vec { + let mut bytes = vec![0_u8; 16 << 10]; + let length = ProtocolCodec::default() + .encode_command_frame_into( + &TypedCommandFrame { + request_id: RequestId::new(request).expect("nonzero request"), + expected_revision: expected, + payload, + }, + &mut bytes, + ) + .expect("command frame"); + bytes.truncate(length); + bytes +} + +/// `edits` as one transaction at the committed revision: the path and the revision the reply +/// names. A refusal panics with its diagnostic codes. +fn transaction(leg: &mut L, edits: &[SessionEdit]) -> (EditPath, u64) { + let revision = leg.revision(); + let reply = leg.command( + CommandPayload::SessionTransactionApply(edits), + ExpectedRevision::Exact(SessionRevision(revision)), + ); + let mut fields = [0_u16; 64]; + match ProtocolCodec::default() + .decode_typed_response(&reply, &mut DecodeScratch::new(&mut fields)) + .expect("a transaction response") + { + DecodedTypedResponseFrame::Success { + header, + payload: DecodedSuccessResponsePayload::SessionTransactionApplied(applied), + } => { + leg.set_revision(header.revision.0); + (applied.edit_path, header.revision.0) + } + DecodedTypedResponseFrame::NonOk { payload, .. } => panic!( + "the transaction is refused: {:?}", + payload + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code.to_string()) + .collect::>() + ), + DecodedTypedResponseFrame::Success { .. } => panic!("another success payload"), + } +} + +/// Which static plan a span of render samples must equal. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Static { + Open, + Closed, +} + +/// What the output must be: spans equal to a static plan, and samples that differ from one (where +/// a ramp starts, the first sample that differs from the static plan the lane leaves; or late in a +/// ramp, a sample that has not yet reached the static plan the lane moves to). +#[derive(Default)] +struct Expect { + equal: Vec<(Range, Static)>, + differ: Vec<(u64, Static)>, +} + +/// The first block boundary at or after `sample`. +fn boundary(sample: u64, quantum: u64) -> u64 { + sample.div_ceil(quantum) * quantum +} + +/// Check one block's bits (`at` its first render sample) against the static plans' blocks. +fn check( + label: &str, + expect: &Expect, + at: u64, + quantum: u64, + out: &[u32], + open: &[u32], + closed: &[u32], +) { + let pick = |which: Static| match which { + Static::Open => open, + Static::Closed => closed, + }; + for offset in 0..quantum { + let sample = at + offset; + let index = offset as usize; + let planes = [index, index + quantum as usize]; + for (span, which) in &expect.equal { + if span.contains(&sample) { + let reference = pick(*which); + assert_eq!( + planes.map(|i| out[i]), + planes.map(|i| reference[i]), + "{label}: sample {sample} equals the {which:?} static plan" + ); + } + } + for (start, which) in &expect.differ { + if *start == sample { + let reference = pick(*which); + assert_ne!( + planes.map(|i| out[i]), + planes.map(|i| reference[i]), + "{label}: sample {sample} differs from the {which:?} static plan (a ramp)" + ); + } + } + } +} + +/// Run `leg` and two static plans on the same host (`open`: `t` unmuted, `closed`: `t` muted) +/// to [`END`], fed the same PCM: before the block at render sample `at`, `before(at, leg)` may +/// edit, and may return a session seek's timeline frame (mirrored on the static plans); every +/// block is checked against `expect`. +fn run( + label: &str, + leg: &mut L, + expect: &Expect, + mut before: impl FnMut(u64, &mut L) -> Option, +) { + let quantum = u32::try_from(leg.quantum()).expect("quantum"); + let mut open = L::boot(&model(quantum, None, false)); + let mut closed = L::boot(&model(quantum, None, true)); + while leg.at() < END { + let at = leg.at(); + if let Some(frame) = before(at, leg) { + leg.seek(frame); + open.seek(frame); + closed.seek(frame); + } + let out = leg.step(); + check( + label, + expect, + at, + leg.quantum(), + &out, + &open.step(), + &closed.step(), + ); + } +} + +/// #1651 gate 1. `t`'s curve closes at 1,000 and opens at 2,500 (neither a multiple of the +/// grid's 64). Each ramp starts at its step sample: every earlier sample equals the static plan +/// the lane leaves and the step sample differs from it; the ramp is still moving at `step + M - 2` +/// (the session mute length, not a shorter one); from the first block boundary at or after the +/// ramp's end (`step + M`) every sample equals the static plan of the new state, up to the next +/// step. Quanta 128, 100 and 37. +/// +/// Test value: red if a mute step snaps to a block or to the grid, ramps over another row's length +/// or over none, or ramps where the static plan does not (a muted lane left at a residue or at a +/// zero of another sign). +#[test] +fn a_mute_curve_mutes_at_its_exact_samples() { + let closed = 1_000..2_500; + for quantum in [128_u32, 100, 37] { + let q = u64::from(quantum); + let label = format!("quantum {quantum}"); + let mut leg = Host::boot(&model( + quantum, + Some(curve(ParameterChannel::Both, closed.clone())), + false, + )); + let expect = Expect { + equal: vec![ + (0..closed.start, Static::Open), + (boundary(closed.start + M, q)..closed.end, Static::Closed), + (boundary(closed.end + M, q)..END, Static::Open), + ], + differ: vec![ + (closed.start, Static::Open), + (closed.start + M - 2, Static::Closed), + (closed.end, Static::Closed), + (closed.end + M - 2, Static::Open), + ], + }; + run(&label, &mut leg, &expect, |_, _| None); + } +} + +/// #1651 gate 1, the seek case. Before the fifth block (render sample `R`) a plain session seek +/// lands on timeline 1,600, inside the curve's muted span: from `R` on the lane is muted exactly +/// (`n = 0`: every sample equals the muted static plan, seeked alike), and the curve's opening at +/// timeline 2,500 ramps from render sample `R + 900`. Quanta 128, 100 and 37. +/// +/// Test value: red if a seek into a muted span ramps instead of setting the stage, keeps the state +/// from before the seek, or places the curve's next step off the new timeline. +#[test] +fn a_seek_into_a_muted_span_mutes_exactly_from_the_seek() { + let closed = 1_000..2_500; + let target = 1_600; + for quantum in [128_u32, 100, 37] { + let q = u64::from(quantum); + let seek_at = 4 * q; + let opens_at = seek_at + (closed.end - target); + let label = format!("quantum {quantum}, seek"); + let mut leg = Host::boot(&model( + quantum, + Some(curve(ParameterChannel::Both, closed.clone())), + false, + )); + let expect = Expect { + equal: vec![ + (0..seek_at, Static::Open), + (seek_at..opens_at, Static::Closed), + (boundary(opens_at + M, q)..END, Static::Open), + ], + differ: vec![(opens_at, Static::Closed), (opens_at + M - 2, Static::Open)], + }; + run(&label, &mut leg, &expect, |at, _| { + (at == seek_at).then_some(target) + }); + } +} + +/// A mute term of `t` the curve does not drive, set on or off as one transaction. +#[derive(Clone, Copy, Debug)] +enum Term { + /// Solo on `u` (a monitor row: `t` is muted by solo). + Solo, + /// VCA `v`'s mute. + Vca, + /// `t`'s own (hand) mute, through `SET_CONTROL` (OQ1 = C). + Hand, +} + +impl Term { + fn edit(self, on: bool) -> SessionEdit { + let (owner, section, control) = match self { + Self::Solo => (Owner::Strip(id("u")), Section::Own, SOLO), + Self::Vca => (Owner::Vca(id("v")), Section::Own, VCA_MUTE), + Self::Hand => (Owner::Strip(id("t")), Section::Builtins, MUTE), + }; + SessionEdit::SetControl { + expected_effect: None, + address: ControlAddress { + owner, + section, + control, + lane: ParameterChannel::Both, + }, + value: f32::from(u8::from(on)), + ramp_samples: None, + } + } + + fn path(self) -> EditPath { + match self { + Self::Solo => EditPath::Monitor, + Self::Vca | Self::Hand => EditPath::Live, + } + } +} + +/// #1651 gate 3 and gate 4's live case. `t`'s curve closes at 2,000 and opens at 3,500; quantum +/// 128. Each of solo on `u`, a VCA mute of `t` and a hand mute of `t` (OQ1 = C): +/// +/// - set at 256 while the curve is open, it mutes `t` (the muted static plan from the ramp's end); +/// - released at 2,560, while the curve is closed, `t` stays muted, and the curve's opening at +/// 3,500 ramps the lane open; +/// - released at 1,024, while the curve is open, the lane ramps open at 1,024, then the curve +/// closes it at 2,000 and opens it at 3,500; +/// - never released, `t` stays muted through the curve's opening. +/// +/// Every edit commits with its own path: solo `monitor`, the VCA and the hand mute `live` (on the +/// browser, through the Worker's apply). +/// +/// Test value: red if solo, a VCA mute or a hand mute overwrites the curve instead of composing +/// with it (a release while closed would open the lane), if a curve step overwrites the term (the +/// opening at 3,500 would unmute a lane the term holds), or if a term on an automated lane is +/// dropped (`model_only`). +#[test] +fn solo_vca_and_hand_mutes_compose_with_the_curve() { + const QUANTUM: u32 = 128; + let q = u64::from(QUANTUM); + let closed = 2_000..3_500; + let on_at = 2 * q; + for term in [Term::Solo, Term::Vca, Term::Hand] { + for release in [Some(20 * q), Some(8 * q), None] { + let label = format!("{term:?}, released at {release:?}"); + let mut leg = Host::boot(&model( + QUANTUM, + Some(curve(ParameterChannel::Both, closed.clone())), + false, + )); + let mut expect = Expect { + equal: vec![(0..on_at, Static::Open)], + differ: vec![(on_at, Static::Open)], + }; + let settled = boundary(on_at + M, q); + match release { + // Held through the curve's opening: closed to the end. + None => expect.equal.push((settled..END, Static::Closed)), + // Released while the curve is closed: closed until the curve opens. + Some(at) if closed.contains(&at) => { + expect.equal.push((settled..closed.end, Static::Closed)); + expect.differ.push((closed.end, Static::Closed)); + expect + .equal + .push((boundary(closed.end + M, q)..END, Static::Open)); + } + // Released while the curve is open: open again, then the curve's own steps. + Some(at) => { + expect.equal.push((settled..at, Static::Closed)); + expect.differ.push((at, Static::Closed)); + expect + .equal + .push((boundary(at + M, q)..closed.start, Static::Open)); + expect.differ.push((closed.start, Static::Open)); + expect + .equal + .push((boundary(closed.start + M, q)..closed.end, Static::Closed)); + expect.differ.push((closed.end, Static::Closed)); + expect + .equal + .push((boundary(closed.end + M, q)..END, Static::Open)); + } + } + let mut revision = leg.revision(); + run(&label, &mut leg, &expect, |at, leg| { + let on = if at == on_at { + true + } else if Some(at) == release { + false + } else { + return None; + }; + // Solo is a monitor write: the revision does not advance (C8). + if term.path() == EditPath::Live { + revision += 1; + } + assert_eq!( + transaction(leg, &[term.edit(on)]), + (term.path(), revision), + "{label}: the term at {at} commits with its path" + ); + None + }); + } + } +} + +/// One `mute` or `fader_db` row of strip `owner` as the host lists it: its lane, the bits of its +/// value and its `automated` flag. +type Row = (Option, u32, bool); + +/// The builtin rows `control` of strip `owner`, paged through `CONTROL_LIST_GET`. +fn listed(leg: &mut L, owner: &str, control: u32) -> 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 = leg.command( + 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 entry.address.owner_id.as_deref() == Some(owner) + && entry.address.section == ControlSection::Builtins + && entry.address.control == Some(control) => + { + Some((entry.address.lane, row.value.to_bits(), row.automated)) + } + _ => None, + })); + if page.eof { + return rows; + } + epoch = page.structure_epoch; + after = page.last_handle; + } +} + +/// Root ruling R18-2 (#1650 D4 for the mute row). A mute entry on `t`'s left lane marks `t`'s +/// left `mute` row `automated` and not its right one; an entry on `both` marks both. The row's +/// value stays the static (hand) mute, which a hand mute on the automated lane moves while the +/// row stays `automated`. `t`'s `fader_db` rows and `u`'s `mute` rows are not marked. +/// +/// Test value: red if the flag stays fader-only (a client would offer a plain toggle on a lane the +/// curve drives), if it marks a lane no entry drives, or if the row lists the curve's value instead +/// of the static mute. +#[test] +fn the_list_marks_a_mute_row_an_entry_drives_automated() { + use protocol::ParameterChannel::{Left, Right}; + let closed = 1_000..2_500; + for (lane, expected) in [ + (ParameterChannel::Left, [true, false]), + (ParameterChannel::Both, [true, true]), + ] { + let label = format!("an entry on {lane:?}"); + let mut leg = Host::boot(&model(128, Some(curve(lane, closed.clone())), false)); + let unmuted = 0.0_f32.to_bits(); + assert_eq!( + listed(&mut leg, "t", MUTE), + vec![ + (Some(Left), unmuted, expected[0]), + (Some(Right), unmuted, expected[1]), + ], + "{label}: t's mute rows" + ); + assert!( + listed(&mut leg, "t", FADER_DB).iter().all(|row| !row.2), + "{label}: t's fader rows are not automated" + ); + assert!( + listed(&mut leg, "u", MUTE).iter().all(|row| !row.2), + "{label}: u's mute rows are not automated" + ); + let mut hand = Term::Hand.edit(true); + if let SessionEdit::SetControl { address, .. } = &mut hand { + address.lane = ParameterChannel::Left; + } + assert_eq!( + transaction(&mut leg, &[hand]).0, + EditPath::Live, + "{label}: a hand mute is live" + ); + assert_eq!( + listed(&mut leg, "t", MUTE), + vec![ + (Some(Left), 1.0_f32.to_bits(), expected[0]), + (Some(Right), unmuted, expected[1]), + ], + "{label}: the hand mute is the left row's value" + ); + } +} From 1d2e2bcc2e7e5e8937a49cf3480002b633a8e87f Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 09:16:33 +0000 Subject: [PATCH 15/23] Carry stored mute automation across a plan swap (#1651 T4) LaneFaderAutomation::adopt adopts the mute channels by address; bits 2 and 3 of continued_cells carry the mute lanes. A carried channel keeps its cell state and terms and repositions when its table changed, its arrival moved or a reposition is still owed; a gained channel repositions. The swap block composes curve || terms once at node time and lands a SetMute over the mute length only when it differs from the stage. A lost mute cell gets a Mute record of the lane's effective mute. R18-4 holds for the strip: a mute adoption keeps the fader move in flight for the unmute. Gate 8 (add, remove, change, each mid-play; Simd8, Simd4, scalar oracle post-ramp) and the combined send-and-strip rebuild now adopt over one length. pcm_digest unchanged. Co-Authored-By: Claude Opus 5.5 --- .../1651-render-stored-mute-automation.md | 82 +++ crates/builtins-compiler/src/lib.rs | 83 ++- crates/controls/src/automation.rs | 28 +- crates/controls/src/lib.rs | 5 +- crates/graph/src/lib.rs | 9 +- crates/host-core/src/prepare.rs | 82 ++- crates/host-core/tests/route_mute.rs | 13 +- .../host-core/tests/stored_mute_automation.rs | 485 ++++++++++++++++++ 8 files changed, 742 insertions(+), 45 deletions(-) diff --git a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md index e9cd21135..4aee5abc7 100644 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md @@ -634,3 +634,85 @@ PR #1712). pass. `check-host-core-policy.sh`, `check-workspace-policy.sh`, `realtime_policy` (161 marked regions, unchanged), fmt, clippy (the five crates, all targets and features, `-D warnings`) and the no-feature `cargo check --all-targets`: ok. + +## Decision record: T4 (batch 18) + +- **Shipped (the strip carry of mute automation).** + - `LaneFaderAutomation::adopt` (`crates/builtins-compiler/src/lib.rs`) adopts the mute + channels by address (strip, row 6, channel): a carried channel takes the predecessor cell's + `CellState` and `terms` (the terms its stage heard; the successor's own `Mute` records, the + join's and later live writes, move them, as they move a fader channel's `S`). It owes a + reposition (`AutomatedMuteCell::reposition`, new) when `continued_cells` bit `2 + channel` is + clear, `a(n)` moved, or the carried cell still owed one; a gained channel keeps its prepared + seed and `terms` and repositions. + - At the lane's next block with node time, `FaderEventDriver::new` repositions the cell at the + block's node time and opens the block with a ramped landing (`SeekMode::Ramp`, a step at + offset 0) over the block's mute jump length. The mute cursor's one compose rule pushes + `SetMute { curve || terms, M }` only when that differs from the stage's mute, so a continued + or an agreeing cell lands nothing (and the 13b rule makes any repeat a no-op). A session + seek in that block lands instead, exactly. T3b's fader cut takes the landing like any mute + event. + - `LaneFaderAutomation::mute_terms`: on a cell that owes a reposition (and has node time this + block) the drain only stores `terms`; the driver composes them with the curve at the swap + block, so the swap lands the one `SetMute` the two changes owe, not one from the prepared + seed and a second that reverses it. + - `controls::continued_mute_lanes` (new, `crates/controls/src/automation.rs`; the fader and + the mute rule share `continued_lanes`). host-core's strip join sets bits 2 and 3 of + `continued_cells`; graph passes the byte unchanged (doc only). + - A lost mute cell: the join emits a `Mute` record of the lane's effective mute in `next` + (`EffectiveStrip::mute`: static, VCA and solo terms) over the lane's mute length, unless the + lane already has a `Mute` record. The 13b rule makes it a no-op where the stage agrees. +- **host-core `prepare.rs` functions touched (strip join only):** the `StripJoin` struct's + `continued_cells` doc, `join_strips` (bits 2 and 3), and `EffectiveDiff::join_level_records` + (the mute release beside the fader release). `resolve_route_carry` is not touched. +- **R18-4 for the strip (argument).** A mute adoption never stretches a live fader move: the + landing is a `SetMute`, which retargets only the lane's gain ramp toward `0` (or the + remembered gain) of the one lane and channel it addresses (dual-mono lanes have separate + stages), and the stage keeps `fader_gain` as the target the live move aimed for (#1638 D2, + D1.6), so after the unmute the lane is back at that gain. No level ramp is carried over `J`, + so B17-R14's window stays fader-only. Gate: below. +- **Gates (all pass)**, `crates/host-core/tests/stored_mute_automation.rs`, every bank width + (Simd8 on AVX2, Simd4) and the scalar oracle, each with and without live controls, quanta 128 + and 100, a swap at block 8, a signed never-zero source; each case compares, plane by plane, + the swapped run with a fresh plan of the new model fed the same PCM from the first block + boundary at or after `swap + M`, with the old model's plan before the swap, and holds the swap + block at 0 allocations, reallocations and frees: + - `a_rebuild_that_adds_a_mute_entry_mutes_from_the_swap_over_the_mute_length` (a gained cell + muted now: one `SetMute(true, 480)` per channel at the swap, the bank lane ramps; then a + gained cell whose curve agrees with a static mute the same rebuild opens: no jump). Red: + adopt without the mute channels (the lane stays open, first difference at 1,536); the + landing exact instead of ramped ("ramps at the swap"); the drain's `terms` composed with the + prepared seed (two jumps at the swap). + - `a_rebuild_that_removes_a_mute_entry_holds_the_static_mute`. Red: the join without the mute + release (the lane stays muted, first difference at 1,536). + - `a_rebuild_that_changes_a_mute_curve_jumps_only_when_the_composed_mute_changes` (muted now: + one jump; changed only after the swap: none). Red: a changed table that continues its + carried cursor (the jump becomes a 64-sample grid event), adopt without the mute channels. + - `a_mute_adoption_keeps_the_fader_move_in_flight_for_the_unmute` (R18-4: a live -12 dB + `FaderDb` over 2,000 in flight across a rebuild that adds a curve mute on at the swap; muted + to `+0.0` after the ramp, and after the curve's unmute equal to the never-swapped plan; live + forms). Red: a stage that remembers the in-flight gain at the mute (temporary mutation in + `builtins::mute_channel`, first difference at 3,200). + - `crates/host-core/tests/route_mute.rs::a_send_edit_and_a_mute_entry_in_one_rebuild_adopt_over_one_length` + un-ignored and green: the strip and the send reach `+0.0` at 1,247 (`swap + M - 1`) on Simd8 + and Simd4. Red at the base and with adopt without the mute channels ("the strip mutes"). +- **Departures.** + - The scalar oracle binds the fader as a graph node, which no plan swap carries: its strip + restarts at the swap (prepared muted exactly, as a fresh plan). Gate 8's scalar leg holds + the post-ramp claim and the swap block's zero allocations only; the route_mute test runs the + bank widths only (its scalar leg failed on "one length for the strip and the send": the strip + is set at 768). Carrying a bound fader node is outside this slice. + - The adoption landing ramps over the block's mute jump length even when a `terms` record of + another length arrives at the same block (the two changes land as one `SetMute`). + - A cell that owes a reposition on a lane with no node time keeps it owed; a `terms` record + there applies at once, composed with the carried target (no curve renders on that lane). +- **Measured.** `audit capi` `pcm_digest` `bffc21e857b5e134` (unchanged), allocations 0, + `total_violations` 0. `realtime_policy`: ok, 168 marked regions (unchanged: the new code sits + in existing regions). `lane_slot_bytes` 392 (unchanged: the new `bool` fits the mute cell's + padding). +- **Ran (all pass).** `cargo test --locked` on `-p builtins-compiler --features test-support`, + `-p host-core --features host-core/test-support,host-core/control-provider` (579), `-p capi`, + `-p host-web --features host-web/test-support`, `-p control-plane --features test-support`, + `-p graph --features test-support`, `-p controls --all-features`; fmt, clippy (builtins-compiler, + controls, host-core, graph; all targets and features, `-D warnings`) and the no-feature + `cargo check --all-targets`. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index e17f6f61a..97321ef71 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -843,6 +843,12 @@ struct AutomatedMuteCell { state: automation::CellState, /// The mute terms the session does not automate. terms: bool, + /// The cell's first block after a plan swap that carried its lane repositions it (#1651 T4, + /// [`LaneFaderAutomation::adopt`]): its table is not the one its state was built on (a + /// changed or gained curve, or a moved `a(n)`). That block composes `curve || terms` at its + /// node time and lands a `SetMute` over the block's mute length when it differs from the + /// stage's mute ([`FaderEventDriver::new`]). Taken by the lane's next block with node time. + reposition: bool, } /// One strip lane's stored fader and mute automation (#1646 D1, #1651 D1): the plan's program and @@ -889,6 +895,7 @@ impl LaneFaderAutomation { cell: cell.cell, state, terms: cell.terms, + reposition: false, } }) }), @@ -934,10 +941,43 @@ impl LaneFaderAutomation { /// 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. + /// retarget record, not here. + /// + /// #1651 T4: each automated mute channel takes the carried cell's event state and `terms` + /// (the mute terms its stage heard) by the same address (the strip, row 6 and the channel, + /// `continued_cells` bits 2 and 3); the successor's own `Mute` records (the join's, a later + /// live write) move `terms` from there, as they move a fader channel's `S`. It owes its first + /// block a reposition when its table does not continue bit for bit or `a(n)` moved (or the + /// carried cell still owed one); a gained channel keeps its prepared state and `terms` and + /// repositions. A repositioned channel composes `curve || terms` at the block's node time and + /// lands a `SetMute` over the mute length only when that differs from the stage's mute; a + /// continued one emits only its own events. A mute channel the successor does not automate is + /// released by the join's `Mute` record. 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.mute.iter_mut().enumerate() { + let Some(cell) = cell else { + continue; + }; + let carried = predecessor.and_then(|predecessor| { + predecessor + .mute + .get(channel)? + .as_ref() + .map(|carried| (carried, predecessor.arrival)) + }); + cell.reposition = match carried { + Some((carried, carried_arrival)) => { + cell.state = carried.state; + cell.terms = carried.terms; + continued_cells & (1 << (MUTE_CONTINUED_SHIFT + channel)) == 0 + || carried_arrival != arrival + || carried.reposition + } + None => true, + }; + } for (channel, cell) in self.channels.iter_mut().enumerate() { let Some(cell) = cell else { continue; @@ -1027,6 +1067,13 @@ impl LaneFaderAutomation { return; }; cell.terms = terms; + // #1651 T4: a repositioned cell's target is not yet the curve at this block's node time + // (the driver repositions it below), so this block's driver composes the new `terms` with + // the curve there and lands the one `SetMute` the two changes owe. A lane with no node + // time this block renders no curve and keeps the cell owed, so the record applies here. + if cell.reposition && self.span.is_some() && self.mute_jump.is_some() { + return; + } let muted = cell.state.target().is_some_and(curve_mutes) || terms; if stage.muted(channel) != muted { let applied = stage.apply( @@ -1351,6 +1398,10 @@ fn block_jumps(time: &GraphBlockTime<'_>) -> Option { }) } +/// The bit of `GraphStripMove::continued_cells` that holds a fader stage's left mute channel; the +/// right one is the next bit (#1651 T4). Bits 0 and 1 are the fader channels (#1648 D2). +const MUTE_CONTINUED_SHIFT: usize = 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; @@ -1701,7 +1752,13 @@ impl<'a> FaderEventDriver<'a> { // (D2, departure 4). let lane_mutes = mutes_used; for (channel, cell) in mute.iter_mut().enumerate() { - let Some(AutomatedMuteCell { cell, state, terms }) = cell.as_mut() else { + let Some(AutomatedMuteCell { + cell, + state, + terms, + reposition, + }) = cell.as_mut() + else { continue; }; let refused = @@ -1712,10 +1769,24 @@ impl<'a> FaderEventDriver<'a> { refused(); continue; }; + // #1651 T4: a repositioned cell (a carried lane whose table changed or was + // gained) is placed at the block's node time in its own table, and the block + // opens with a ramped landing over the mute length, which pushes `SetMute` only + // when `curve || terms` differs from the stage's mute. A seek step in the block + // lands instead (the seek sets the lane exactly where it applies). + let adopted = core::mem::replace(reposition, false); + if adopted { + state.reposition(&cell_program, span.start); + } + let mut mode = controls::MUTE_CELL_RULE.seek; let step = span .step .map(|step| (step.offset, step.timeline)) .or_else(|| { + if adopted { + mode = automation::SeekMode::Ramp; + return Some((0, span.start)); + } let value = automation::value_at(cell_program.segments(), span.start); (first_block && state.target().map(f32::to_bits) != Some(value.to_bits())) .then_some((0, span.start)) @@ -1736,13 +1807,7 @@ impl<'a> FaderEventDriver<'a> { channel, terms: *terms, muted, - events: automation::CellEvents::new( - cell_program, - state, - node, - controls::MUTE_CELL_RULE.seek, - mute_jump, - ), + events: automation::CellEvents::new(cell_program, state, node, mode, mute_jump), next: None, pushed: None, }; diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index 080fdb53f..2285075d2 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -574,14 +574,34 @@ pub fn continued_fader_lanes( current: &SessionModel, next: &SessionModel, strip: &StableId, +) -> [bool; 2] { + continued_lanes(current, next, strip, fader_automation) +} + +/// The lanes of strip `strip`'s mute (`[left, right]`) that both models automate with the same +/// table, by the rule of [`continued_fader_lanes`] on the mute row ([`mute_automation`], #1651 +/// T4): such a lane's mute cell continues its carried event state verbatim, and every other +/// carried mute cell is repositioned at adoption. +#[must_use] +pub fn continued_mute_lanes( + current: &SessionModel, + next: &SessionModel, + strip: &StableId, +) -> [bool; 2] { + continued_lanes(current, next, strip, mute_automation) +} + +/// The lanes that `entries` gives an entry in both models with the same segments. +fn continued_lanes<'a>( + current: &'a SessionModel, + next: &'a SessionModel, + strip: &StableId, + entries: fn(&'a SessionModel, &StableId) -> [Option<&'a Automation>; 2], ) -> [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), - ); + let (before, after) = (entries(current, strip), entries(next, strip)); [0, 1].map(|lane| { matches!((before[lane], after[lane]), (Some(before), Some(after)) if same_segments(before, after)) diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index de926ee01..afa3fa98c 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -47,8 +47,9 @@ mod view; pub use automation::{ CellRule, ControlDiagnostic, FADER_CELL_RULE, MUTE_CELL_RULE, automated_cell, automated_fader_lanes, automation_diagnostics, automation_row_renders, continued_fader_lanes, - fader_automation, fader_row, filter_statics_changed_under_automation, filter_under_automation, - mute_automation, mute_row, rendered_automation_changed, stored_segments, + continued_mute_lanes, fader_automation, fader_row, filter_statics_changed_under_automation, + filter_under_automation, mute_automation, mute_row, rendered_automation_changed, + stored_segments, }; pub use descriptor::{ AutomationRule, Choice, ControlDescriptor, ControlScope, DomainRule, Lanes, Motion, check, diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index a083ae9d6..62acf8614 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -4174,10 +4174,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. + /// The fader stage's automated cells whose stored automation table the successor's model + /// keeps bit for bit from the predecessor's (#1648 D2): the fader cells (bit 0 left, bit 1 + /// right) and the mute cells (#1651 T4: bit 2 left, bit 3 right). Such a cell's carried event + /// state continues verbatim, and every other carried cell is repositioned in its own table at + /// adoption. Graph passes the byte to the stage unchanged. `0` for a matrix move. pub continued_cells: u8, } diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index b9c095962..81b2173db 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -4861,6 +4861,13 @@ impl<'a> EffectiveDiff<'a> { /// record. The live classifier never reaches a removal (#1648 D1 rebuilds it), so this is the /// join's alone. /// + /// #1651 T4, a mute cell lost: each lane whose stored mute automation the change removes gets + /// a `Mute` record of its effective mute in `next` (`controls::EffectiveStrip::mute`, the + /// static mute with its VCA and solo terms) over the lane's mute length, for the same reason: + /// its carried stage holds `curve || terms`. The stage's batch 13b rule makes the record a + /// no-op when the stage already agrees. A released lane whose effective mute changed already + /// has its record. + /// /// # Errors /// /// [`OutOfDomain`] as [`fader_records`]. @@ -4872,35 +4879,47 @@ impl<'a> EffectiveDiff<'a> { 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] { + let next = self.rules.next.structure(); + let lost = + |lanes: [bool; 2], kept: [bool; 2]| [0, 1].map(|lane| lanes[lane] && !kept[lane]); + let fader_released = lost( + controls::automated_fader_lanes(self.rules.current, change.id), + controls::automated_fader_lanes(next, change.id), + ); + let automates = |model: &SessionModel, strip: &session::StableId| { + controls::mute_automation(model, strip).map(|entry| entry.is_some()) + }; + let mute_released = lost( + automates(self.rules.current, change.id), + automates(next, change.id), + ); + if fader_released == [false; 2] && mute_released == [false; 2] { return Ok(level); } - 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]); + let mut released = [fader_released, mute_released]; 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; - } + let (kind, lanes) = match record { + TrackFaderRecord::FaderDb { lanes, .. } => (0, lanes), + TrackFaderRecord::Mute { lanes, .. } => (1, lanes), + }; + let covered = match lanes { + BuiltinLaneSelector::Left => [true, false], + BuiltinLaneSelector::Right => [false, true], + BuiltinLaneSelector::Both => [true, true], + }; + for (released, covered) in released[kind].iter_mut().zip(covered) { + *released &= !covered; } } - if released == [false; 2] { - return Ok(level); - } + let [fader_released, mute_released] = released; let lengths = self.rules.lengths(&change); - let db = self.after.strips[after].fader_db; + let strip = &self.after.strips[after]; + let (db, muted) = (strip.fader_db, strip.mute); // 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. + // no record of its kind 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, + fader_released, db[0].to_bits() == db[1].to_bits() && lengths.fader[0] == lengths.fader[1], ) { if let Some(slot) = free.next() { @@ -4911,6 +4930,18 @@ impl<'a> EffectiveDiff<'a> { }); } } + for (lanes, lane) in lane_records( + mute_released, + muted[0] == muted[1] && lengths.mute[0] == lengths.mute[1], + ) { + if let Some(slot) = free.next() { + *slot = Some(TrackFaderRecord::Mute { + lanes, + muted: muted[lane], + smoothing_samples: lengths.mute[lane], + }); + } + } Ok(level) } @@ -5463,8 +5494,9 @@ struct StripJoin<'a> { 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 - /// [`graph::GraphStripMove::continued_cells`]. + /// [`controls::continued_fader_lanes`]), bit 0 left and bit 1 right, and the mute lanes of + /// the same rule (#1651 T4, [`controls::continued_mute_lanes`]), bit 2 left and bit 3 right: + /// the fader move's [`graph::GraphStripMove::continued_cells`]. continued_cells: u8, } @@ -5588,7 +5620,11 @@ fn join_strips<'a>( Ok(Some(record)) => FamilyJoin::Live(record), }; let continued = controls::continued_fader_lanes(committed, model, strip.id); - let continued_cells = u8::from(continued[0]) | (u8::from(continued[1]) << 1); + let continued_mute = controls::continued_mute_lanes(committed, model, strip.id); + let continued_cells = u8::from(continued[0]) + | (u8::from(continued[1]) << 1) + | (u8::from(continued_mute[0]) << 2) + | (u8::from(continued_mute[1]) << 3); joins.push(StripJoin { id, fader, diff --git a/crates/host-core/tests/route_mute.rs b/crates/host-core/tests/route_mute.rs index ccb5e7e84..8502d7240 100644 --- a/crates/host-core/tests/route_mute.rs +++ b/crates/host-core/tests/route_mute.rs @@ -2138,12 +2138,16 @@ fn a_rebuild_that_removes_the_mute_entry_reopens_the_send() { /// strip's lane and the send reach exact `+0.0` on the same output sample, `M` samples after the /// swap block starts. /// +/// The bank widths only: the scalar oracle binds the strip's fader as a graph node, which no +/// plan swap carries, so its strip restarts at the swap (muted exactly, as a fresh plan) while the +/// send carries. +/// /// Test value: red if the join's seven-word record (the gain edit's `SendGain` length) governs the /// send's mute instead of the curve's own jump over the mute length, or if the strip and the send -/// adopt over different lengths. +/// adopt over different lengths (#1651 T4: red when a carried strip lane does not take on a +/// gained mute curve). #[cfg(feature = "test-support")] #[test] -#[ignore = "#1652 graph fold (U2a): the carried record is composed with the heard curve there"] fn a_send_edit_and_a_mute_entry_in_one_rebuild_adopt_over_one_length() { use follow::*; use successor::{QUANTUM, SWAP_BLOCK}; @@ -2155,7 +2159,10 @@ fn a_send_edit_and_a_mute_entry_in_one_rebuild_adopt_over_one_length() { let quiet = |_: usize, _: &mut host_core::HostLiveControlHandles, _: &mut engine::realtime::PlanRevisionGate| {}; - for backend in follow::backends() { + for backend in follow::backends() + .into_iter() + .filter(|backend| *backend != Backend::Scalar) + { let what = format!("{backend:?}"); let (swapped, _) = live_swap(&a, Some((&b, &a)), backend, &pcm, quiet); assert_quiet(&swapped, &what); diff --git a/crates/host-core/tests/stored_mute_automation.rs b/crates/host-core/tests/stored_mute_automation.rs index 52186ab44..ee63b8d24 100644 --- a/crates/host-core/tests/stored_mute_automation.rs +++ b/crates/host-core/tests/stored_mute_automation.rs @@ -1032,3 +1032,488 @@ fn a_hold_that_starts_and_ends_inside_a_block_moves_at_its_end() { "the dropped cut lands no move; the last unmute's does" ); } + +// #1651 T4, gate 8: the strip carry of mute automation. + +/// The block a carry case's successor is swapped in at. +const SWAP_BLOCK: usize = 8; + +/// Gate 8's quanta. +const CARRY_QUANTA: [u32; 2] = [128, 100]; + +/// How a carry case's plans are prepared: no live control (the join cannot write a record, so a +/// strip whose effective mute moves restarts), or live controls (the C ABI's form). +#[derive(Clone, Copy, Debug)] +struct CarryForm { + backend: graph_compiler::Backend, + live: bool, +} + +/// Every bank width of the target and the scalar oracle (R17-4), each with and without live +/// controls. The scalar oracle binds the fader as a graph node, which no plan swap carries: its +/// strip restarts at the swap and its leg holds the post-ramp claim only. +fn carry_forms() -> Vec { + let backends = [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + graph_compiler::Backend::Scalar, + ]; + backends + .into_iter() + .flat_map(|backend| [false, true].map(|live| CarryForm { backend, live })) + .collect() +} + +/// A signed two-plane source that is never zero (a zero's sign and a gain's bits both show). +fn carry_pcm(frames: usize) -> [Vec; 2] { + let plane = |channel: u32| { + (0..frames as u32) + .map(|frame| { + let step = frame + .wrapping_mul(7_919) + .wrapping_add(channel * 31) + .wrapping_add(17) + % 1_999; + (step as f32 - 999.5) / 2_000.0 + }) + .collect::>() + }; + [plane(0), plane(1)] +} + +/// What a carry run rendered. +struct CarryRun { + out: [Vec; 2], + /// The swap block's `(allocations, reallocations, frees)` on the render thread. + swap_allocations: (u64, u64, u64), + /// The successor's `restarted_strips()`. + restarted: Vec>, +} + +/// `model` compiled with this file's caps. +fn compiled(model: &SessionModel) -> host_core::CompiledSession { + let document = canonical_session_json(model).expect("canonical"); + host_core::compile_host_session(&document, &caps(model)) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))) +} + +/// `a` prepared in `form` and rendered for [`SWAP_BLOCK`] blocks (`edit` runs before each block, +/// writing through its live handles and gate), then, with `b = Some((next, committed))`, `next` +/// prepared as its successor on the committed model `committed`, swapped in and rendered to +/// `frames`; with `b = None`, `a` alone for every block. Fed `pcm` from frame 0. +fn carry_run( + a: &SessionModel, + b: Option<(&SessionModel, &SessionModel)>, + form: CarryForm, + pcm: &[Vec; 2], + frames: usize, + mut edit: impl FnMut( + usize, + &mut host_core::HostLiveControlHandles, + &mut engine::realtime::PlanRevisionGate, + ), +) -> CarryRun { + use engine::realtime::{PlanExchangeConfig, SwapOutcome, plan_exchange}; + let quantum = a.quantum_frames as usize; + let caps = caps(a); + let request = host_core::HostLiveControlRequest::default(); + let fail = |failure: host_core::PrepareDiagnostics| -> ! { + panic!("prepare: {}", String::from_utf8_lossy(failure.as_bytes())) + }; + let first = compiled(a); + let (prepared, mut handles) = if form.live { + let (prepared, handles) = host_core::test_only_prepare_host_runtime_with_live_controls_on( + &first, + &caps, + &request, + form.backend, + ) + .unwrap_or_else(|failure| fail(failure)); + (prepared, Some(handles)) + } else { + ( + host_core::test_only_prepare_host_runtime_on(&first, &caps, form.backend) + .unwrap_or_else(|failure| fail(failure)), + None, + ) + }; + let PreparedHost { + mut plan, + mut sources, + inventory, + .. + } = prepared; + let mut gate = plan.take_revision_gate(); + let (mut publisher, mut owner, _retirer) = plan_exchange( + plan, + PlanExchangeConfig { + retirement_capacity: core::num::NonZeroUsize::MIN, + }, + ) + .unwrap_or_else(|_| panic!("plan exchange")); + bench_support::alloc::assert_installed(); + let submit = |sources: &mut host_core::SourceControlSet, at: usize| { + sources + .submit( + NINE_SOURCE, + SourceSubmission { + generation: 1, + start_frame: at as u64, + sample_rate_hz: RATE, + planes: &[&pcm[0][at..at + quantum], &pcm[1][at..at + quantum]], + frames: quantum as u32, + end_of_region: false, + }, + ) + .expect("source block"); + }; + let mut out = [Vec::new(), Vec::new()]; + let mut swap_allocations = (0, 0, 0); + let mut restarted = Vec::new(); + let mut successor_sources = None; + let mut block = 0; + while block * quantum < frames { + if block == SWAP_BLOCK + && let Some((next, committed)) = b + { + let next = compiled(next); + let committed = compiled(committed).normalized_model().clone(); + let revision = gate.as_ref().map_or(1, |gate| gate.last_published()) + 1; + let base = host_core::SuccessorBase::new(&inventory, &committed, revision); + let mut prepared = if form.live { + host_core::test_only_prepare_host_runtime_with_live_controls_successor_on( + &next, + &caps, + &request, + base, + form.backend, + ) + .map(|(prepared, _)| prepared) + } else { + host_core::test_only_prepare_host_runtime_successor_on( + &next, + &caps, + base, + form.backend, + ) + } + .unwrap_or_else(|failure| fail(failure)); + prepared.sources.adopt_persisting(&mut sources); + restarted = prepared.restarted_strips().to_vec(); + let PreparedHost { + mut plan, + sources: next_sources, + .. + } = prepared; + plan.take_revision_gate() + .expect("the successor's gate") + .publish(revision); + publisher + .reserve_replacement(plan, engine::realtime::PlanAdoption::Next) + .unwrap_or_else(|_| panic!("reserve the successor")) + .commit(); + successor_sources = Some(next_sources); + } + let at = block * quantum; + submit(successor_sources.as_mut().unwrap_or(&mut sources), at); + if let (Some(handles), Some(gate)) = (handles.as_mut(), gate.as_mut()) + && successor_sources.is_none() + { + edit(block, handles, gate); + } + let mut samples = vec![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(); + let report = owner + .render_contiguous(RenderIo { output }, at as u64) + .unwrap_or_else(|error| panic!("render: {error:?}")); + let delta = bench_support::alloc::current_thread_delta_since(mark); + let swapped = report.swap == SwapOutcome::Applied; + assert_eq!( + swapped, + b.is_some() && block == SWAP_BLOCK, + "the successor is adopted at the swap block" + ); + if swapped { + swap_allocations = (delta.allocations, delta.reallocations, delta.deallocations); + } + out[0].extend_from_slice(&samples[..quantum]); + out[1].extend_from_slice(&samples[quantum..]); + block += 1; + } + drop(handles); + CarryRun { + out: out.map(|mut plane| { + plane.truncate(frames); + plane + }), + swap_allocations, + restarted, + } +} + +/// `model` prepared fresh in `form` and fed `pcm` from frame 0: the reference. +fn fresh_run( + model: &SessionModel, + form: CarryForm, + pcm: &[Vec; 2], + frames: usize, +) -> [Vec; 2] { + carry_run(model, None, form, pcm, frames, |_, _, _| {}).out +} + +/// Gate 8 on one case: `a` rendered, `b` swapped in at [`SWAP_BLOCK`] on `a`'s committed model, +/// on every carry form and at both quanta. From the first block boundary at or after the +/// adoption ramp's end (`M` samples from the swap) each plane equals a fresh plan of `b` fed the +/// same PCM, bit for bit; before the swap it equals `a`'s; the swap block allocates and frees +/// nothing; a bank's strip carries. `ramps` asserts that the swap ramps (a bank's carried lane +/// differs from the fresh plan inside the ramp). Returns the mute events the bank legs' successors +/// pushed at the swap sample, per quantum: `(channel, sample, muted, ramp)`. +fn carry_case( + label: &str, + a: &SessionModel, + b: &SessionModel, + ramps: bool, +) -> Vec> { + let mut swap_events = Vec::new(); + for quantum in CARRY_QUANTA { + let (mut a, mut b) = (a.clone(), b.clone()); + a.quantum_frames = quantum; + b.quantum_frames = quantum; + let q = quantum as usize; + let frames = 4_096_usize.div_ceil(q) * q; + let pcm = carry_pcm(frames); + let at = SWAP_BLOCK * q; + let settled = (at + M as usize).div_ceil(q) * q; + for form in carry_forms() { + let what = format!("{label}, {form:?}, quantum {quantum}"); + let bank = form.backend != graph_compiler::Backend::Scalar; + let fresh = fresh_run(&b, form, &pcm, frames); + let before = fresh_run(&a, form, &pcm, frames); + let _ = mute_events(); + let swapped = carry_run(&a, Some((&b, &a)), form, &pcm, frames, |_, _, _| {}); + let events = mute_events(); + assert_eq!( + swapped.swap_allocations, + (0, 0, 0), + "{what}: the swap block" + ); + if bank && form.live { + assert!(swapped.restarted.is_empty(), "{what}: the strip carries"); + } + for plane in 0..2 { + assert_eq!( + first_difference(&swapped.out[plane][..at], &before[plane][..at]), + None, + "{what}: plane {plane} before the swap" + ); + assert_eq!( + first_difference(&swapped.out[plane][settled..], &fresh[plane][settled..]) + .map(|index| index + settled), + None, + "{what}: plane {plane} from the first block boundary after the ramp" + ); + if ramps && bank && swapped.restarted.is_empty() { + assert!( + first_difference( + &swapped.out[plane][at..settled], + &fresh[plane][at..settled] + ) + .is_some(), + "{what}: plane {plane} ramps at the swap, it is not set" + ); + } + } + if bank && form.live { + swap_events.push( + events + .into_iter() + .filter(|event| event.1 == at as u64) + .collect(), + ); + } + } + } + swap_events +} + +/// A two-track [`plain`] model (two bank lanes) with track 0's mute curve `entry`, if any. +fn with_mute(quantum: u32, entry: Option<(f32, &[(u64, f32)])>) -> SessionModel { + let mut model = plain(2, quantum); + if let Some((from, steps)) = entry { + model.automation = vec![mute_entry(&model, 0, ParameterChannel::Both, from, steps)]; + } + model +} + +/// Every swap-sample mute event of a bank leg is one `SetMute` per channel to `muted` over `M`. +fn assert_adoption_jumps(events: &[Vec<(usize, u64, bool, u32)>], muted: bool, label: &str) { + assert!(!events.is_empty()); + for events in events { + let mut channels: Vec = events.iter().map(|event| event.0).collect(); + channels.sort_unstable(); + assert_eq!(channels, [0, 1], "{label}: one adoption jump per channel"); + assert!( + events.iter().all(|event| event.2 == muted && event.3 == M), + "{label}: to {muted} over the mute length: {events:?}" + ); + } +} + +/// #1651 gate 8, a gained mute cell: a rebuild at block 8 adds a mute entry on track 0 (both +/// lanes) that holds the lane muted until timeline 2,600. The carried lane composes `curve || +/// terms` at the swap block's node time and mutes over `M` from the swap (one `SetMute` per +/// channel), then follows the curve's own unmute at 2,600: from the first block boundary after +/// the ramp the output equals a fresh plan of the new model. +/// +/// Test value: red if a carried strip lane does not take on a gained mute curve (it stays open +/// until the curve's next event), sets it exactly instead of ramping, ramps over another length, +/// or misplaces the gained cell's cursor (its unmute at 2,600 is missed or repeated); and red if +/// a `terms` record at the swap block composes with the gained cell's prepared seed instead of +/// the curve at the swap (the drain unmutes and the driver mutes again: a jump the stage did not +/// owe). +#[test] +fn a_rebuild_that_adds_a_mute_entry_mutes_from_the_swap_over_the_mute_length() { + let a = with_mute(128, None); + let b = with_mute(128, Some((1.0, &[(2_600, 0.0)]))); + let events = carry_case("added", &a, &b, true); + assert_adoption_jumps(&events, true, "added"); + // The same rebuild also opens the lane's static mute: the join's `Mute` record (the new + // `terms`) reaches the gained cell at the swap block's drain, before the driver repositions + // it. The curve is muted at the swap (300 to 2,600), so `curve || terms` equals the stage's + // (statically muted) mute and the swap lands nothing. + let mut a = with_mute(128, None); + (a.tracks[0].fader.left_mute, a.tracks[0].fader.right_mute) = (true, true); + let b = with_mute(128, Some((0.0, &[(300, 1.0), (2_600, 0.0)]))); + let events = carry_case("added, terms open", &a, &b, false); + assert!( + events.iter().all(Vec::is_empty), + "added, terms open: no jump at the swap: {events:?}" + ); +} + +/// #1651 gate 8, a lost mute cell: the predecessor's curve holds track 0 muted until timeline +/// 3,600, and a rebuild at block 8 removes the entry. The join sends the lane a `Mute` record of +/// its effective (static) mute, open, so the carried lane unmutes over `M` from the swap and +/// equals a fresh plan of the new model from the first block boundary after the ramp. +/// +/// Test value: red if a lost mute cell keeps the curve's state (the lane stays muted until the +/// next live mute record), or if the release ramps over another length. +#[test] +fn a_rebuild_that_removes_a_mute_entry_holds_the_static_mute() { + let a = with_mute(128, Some((1.0, &[(3_600, 0.0)]))); + let b = with_mute(128, None); + carry_case("removed", &a, &b, true); +} + +/// #1651 gate 8, a changed curve: (a) the predecessor's curve mutes at 3,000 and the new one is +/// muted from 500 to 2,600, so at the swap the repositioned cell composes muted and jumps over +/// `M`, then unmutes at 2,600; (b) the new curve differs only after the swap (a mute from 2,000 +/// to 3,200 instead of from 3,000), so the swap lands no jump and the lane follows the new +/// table's steps. Each equals a fresh plan of the new model from the first block boundary after +/// the ramp. +/// +/// Test value: red if a changed table continues its carried cursor (the old table's position in +/// the new one misses or repeats a step), if the adoption jump is missing, repeated, or lands +/// when the composed mute equals the stage's. +#[test] +fn a_rebuild_that_changes_a_mute_curve_jumps_only_when_the_composed_mute_changes() { + let a = with_mute(128, Some((0.0, &[(3_000, 1.0)]))); + let b = with_mute(128, Some((0.0, &[(500, 1.0), (2_600, 0.0)]))); + let events = carry_case("changed, muted now", &a, &b, true); + assert_adoption_jumps(&events, true, "changed, muted now"); + let b = with_mute(128, Some((0.0, &[(2_000, 1.0), (3_200, 0.0)]))); + let events = carry_case("changed later", &a, &b, false); + assert!( + events.iter().all(Vec::is_empty), + "changed later: no jump at the swap: {events:?}" + ); +} + +/// #1651 T4, R18-4: a live fader move in flight on track 0 across a rebuild that adds a curve +/// mute which is on at the swap. A `FaderDb` of -12 dB over 2,000 samples is written before block +/// 5 and is still ramping at the swap (block 8); the successor's committed model holds the -12 +/// dB and a mute curve muted until timeline 2,600. The adoption mutes the lane over `M` (dual-mono +/// lanes have separate stages, so the mute retargets only the lane's gain to `0`), and the stage +/// remembers the fader gain the move aimed for (#1638 D2, D1.6). After the curve's unmute at +/// 2,600 and its ramp the lane equals the uninterrupted plan (the move completed, never muted), +/// bit for bit. +/// +/// Test value: red if the mute adoption stretches or cuts the live fader move (the lane comes +/// back at the gain the move had reached at the swap, or ramps on from it), or if the stage +/// remembers a gain other than the move's target. +#[test] +fn a_mute_adoption_keeps_the_fader_move_in_flight_for_the_unmute() { + const DB: f32 = -12.0; + const MOVE: u32 = 2_000; + let write = |block: usize, + handles: &mut host_core::HostLiveControlHandles, + gate: &mut engine::realtime::PlanRevisionGate| { + use builtins_compiler::TestOnlyWriteGate; + if block == 5 { + gate.write_published(|stamp| { + handles.strip_controls[0].fader.write( + stamp, + builtins_compiler::TrackFaderRecord::FaderDb { + lanes: builtins::BuiltinLaneSelector::Both, + db: DB, + smoothing_samples: MOVE, + }, + ); + }); + } + }; + for quantum in CARRY_QUANTA { + // One track, so the output is track 0's lane. + let a = plain(1, quantum); + let mut committed = a.clone(); + committed.tracks[0].fader.left_db = DB; + committed.tracks[0].fader.right_db = DB; + let mut b = committed.clone(); + b.automation = vec![mute_entry( + &b, + 0, + ParameterChannel::Both, + 1.0, + &[(2_600, 0.0)], + )]; + let q = quantum as usize; + let frames = 4_096_usize.div_ceil(q) * q; + assert!( + 5 * q + MOVE as usize > SWAP_BLOCK * q, + "the move is in flight at the swap" + ); + let pcm = carry_pcm(frames); + let open = (2_600 + M as usize).div_ceil(q) * q; + for form in carry_forms().into_iter().filter(|form| form.live) { + let what = format!("{form:?}, quantum {quantum}"); + let uninterrupted = carry_run(&a, None, form, &pcm, frames, write).out; + let swapped = carry_run(&a, Some((&b, &committed)), form, &pcm, frames, write); + assert_eq!( + swapped.swap_allocations, + (0, 0, 0), + "{what}: the swap block" + ); + if form.backend != graph_compiler::Backend::Scalar { + assert!(swapped.restarted.is_empty(), "{what}: the strip carries"); + } + for (plane, (out, uninterrupted)) in swapped.out.iter().zip(&uninterrupted).enumerate() + { + let muted = &out[(SWAP_BLOCK * q + M as usize)..2_600]; + assert_eq!( + muted.iter().position(|sample| sample.to_bits() != 0), + None, + "{what}: plane {plane} is muted after the adoption ramp" + ); + assert_eq!( + first_difference(&out[open..], &uninterrupted[open..]) + .map(|index| index + open), + None, + "{what}: plane {plane} after the unmute equals the uninterrupted move" + ); + } + } + } +} From 1132a1a441023ea418c168e6787545799b8f4703 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 09:23:09 +0000 Subject: [PATCH 16/23] Flag a following route carried into a six-word route and clear the kernel's panic path (#1652, fold 2) - MAJOR-A: a rebuild that carries a following route into a six-word route reports TransitionFallback on both hosts (the route join sets it; the shared commit reads it). Conservative: it can over-report on a rebuild that removes a strip's last mute automation while a following send exists; TBD51 (placeholder) carries the columns exactly and removes it. - MINOR-B: route_mix_column_ramps_block clamps its split, so the wasm f32x4 instantiation has no panic site; no bits move. - MINOR-C: the activation-offset gate also runs into the session output; NIT-D: a heard curve on a lane the successor no longer automates is forgotten; NIT-E: a successor of a plan that never rendered takes its pending comparison (host-core gate). Co-Authored-By: Claude Opus 5.5 --- ...mated-mute-on-following-sends-in-render.md | 80 ++++++++++ ...moves-exactly-into-a-six-word-successor.md | 59 +++++++ crates/capi/include/miso_engine_v1.h | 4 +- crates/capi/src/abi.rs | 4 +- crates/capi/src/runtime/live_tests.rs | 80 ++++++++++ crates/control-plane/src/control.rs | 24 ++- crates/graph/src/lib.rs | 6 +- crates/graph/src/runtime.rs | 130 ++++++++++++++-- crates/host-core/src/prepare.rs | 82 +++++++++- crates/host-core/tests/route_mute.rs | 145 ++++++++++++++++++ crates/lane/src/kernels.rs | 10 +- docs/C_ABI_V1_QUALIFICATION.md | 4 +- hosts/host-web/tests/control_parity.rs | 99 ++++++++++++ .../miso-engine-v1-audio-worklet-host.d.ts | 3 +- sdk/src/browser/shipped-host.d.ts | 3 +- 15 files changed, 701 insertions(+), 32 deletions(-) create mode 100644 .github/ISSUE_SPECS/TBD51-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md diff --git a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md index 41a6eb143..069415a3a 100644 --- a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md +++ b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md @@ -711,3 +711,83 @@ ramps). Files: `crates/graph/src/{lib.rs,runtime.rs}`, `crates/lane/src/kernels. (the pre-fold code) -> MAJOR-3 six-word red. M1 analogue (drop the `return` after the record) -> MAJOR-3 six-word red. Kernel: right column read from the left ramp -> `each_column...` red. The one-ramping part assigning the ramping column's target -> `each_column...` red. + +## Decision record: U2a fold 2 (batch 18) + +Fold of the U2a fold-1 verification (FAIL narrow at `977d6969c`), the last fold round for #1652. +Root (2026-10-17) accepted the plan as written: MAJOR-A is a conservative flag, gated both ways, +the exact form is TBD51 (phase 2); MINOR-B must land. + +- **MAJOR-A (seven -> six re-times a live move and reported EXACT).** Two render forms, both + only on a carry of a following route into a six-word route: (a) `finish_six_word_carry` reopens a + record composed with the heard curve over `J`, not the record's `R`; (b) `ColumnRamps::one_ramp` + folds two column moves into one ramp over the longer remaining length. Shipped the B17-R14 + pattern (control-time `CandidateOutcome::TransitionFallback`, no render edit): host-core's send + join `resolve_route_carry` now reads the successor row's `following` too and sets + `RouteCarry::follow_into_six_word` when a move's predecessor follows and its successor does not; + the plan's inventory keeps it (`PlanStateInventory::carries_a_following_route_into_a_six_word_route`, + one `bool`, no heap byte), and the control plane's rebuild (`crates/control-plane/src/control.rs`) + marks the candidate `TransitionFallback` when it or B17-R14's level condition holds. Both hosts + run that one commit. The flag does not open B17-R14's level-move window (it is not a level move: + the `landing` fold now reads the level condition alone, which keeps B17-R13 tranche 5's rule + as it was). **Conservative:** it over-reports on every rebuild that removes a strip's last mute + automation while a following send of it exists, moving or not; TBD51 + (`.github/ISSUE_SPECS/TBD51-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md`) + gives the exact form and deletes the flag. Outcome doc text updated in + `crates/capi/src/abi.rs`, `crates/capi/include/miso_engine_v1.h`, + `docs/C_ABI_V1_QUALIFICATION.md` and the two shipped-host `.d.ts` copies. +- **MINOR-B (kernel panic path).** `route_mix_column_ramps_block` splits each rest at + `one = min(either - both, min(left_rest.len(), right_rest.len()))`, so the wasm `f32x4` + instantiation keeps no `mid > len` path. No bit moves (`either - both` is never above the + rests' length; `route_ramp` passes). +- **MINOR-C.** `an_opening_route_is_left_out_of_its_destination_before_its_offset` runs every case + twice: into a submix (`reduce_gated`) and into the session Output (`Some(HostMaster)`, + `reduce_gated_into`, host planes pre-filled with stale words). +- **NIT-D.** New gate for the carried first block's `else if !automated { *curve = false }`: a + three-plan chain (P1 automates both lanes and hears the left curve on; P2 automates only the + right; P3 automates the left again and gets a join record). The record composes with P2's heard + curve, so both columns move over `R`. +- **NIT-E (fold 1's MINOR-2) is reachable and gated in host-core.** A render call that adopts a + plan at the boundary and is then refused (a time discontinuity) displaces nothing yet, but the + plan has rendered no block; the next swap adopts from it. The proof comment at + `adopt_predecessor` names the gate. +- **Record (c), precedent.** A route that opens inside a block and is muted again by a seek's + `Set` in the same block sums `[set, q)` (no end offset), as the #1217/#1220 rule sums a six-word + route that a block mutes: a precedent, not a new limit. +- **Record (d), departure for root to accept (accepted 2026-10-17).** `RouteActivity` grows by + 16 B (the `from: Box<[u32]>` header) once per plan that has live or gated routes, automation or + not: measured `size_of::()` 80 (64 before, x86-64), charged by `size_of` in + `route_activity_bound_bytes`. This departs from R18-5's literal "no new bytes". The offsets + themselves (one `u32` per prepared route) are allocated and charged only in a plan that binds a + following route. `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 352 and `LIVE_ROUTE_OWNER_BYTES` 72 unchanged. +- **Paths.** `crates/host-core/src/prepare.rs` functions touched: `resolve_route_carry` (the flag), + `prepare_host_runtime_with_live_controls_policy_and_spectrum` (one inventory field line), + `struct RouteCarry` (the field), `PlanStateInventory` (the field, a new accessor + `carries_a_following_route_into_a_six_word_route`, and `false` in `at_discontinuity` and + `test_only_floors_only`), and the test module (`live_routes_join_by_route_id`, one inventory + literal, new `the_join_flags_a_following_route_carried_into_a_six_word_one`). No strip-join + code. Mechanical, + outside the listed paths: `crates/control-plane/src/control.rs` (the outcome term), the outcome + doc text above, `crates/graph/src/lib.rs` (a proof comment only). +- **Gates (all pass).** + - MAJOR-A: `crates/capi/src/runtime/live_tests.rs` + `a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback` (seven -> six sent at + block 8 while the send's columns ramp toward the curve's mute -> `TRANSITION_FALLBACK`, count 1; + seven -> seven and no following route -> `EXACT`, count 0); + `hosts/host-web/tests/control_parity.rs` + `a_rebuild_that_drops_a_following_route_to_six_words_reports_the_fallback_alike` (the same three + cases on both hosts, the browser status carries the C ABI's watermark, same bits); + `crates/host-core/src/prepare.rs` `the_join_flags_a_following_route_carried_into_a_six_word_one` + (the four kind pairs). + - MINOR-C: `an_opening_route_is_left_out_of_its_destination_before_its_offset` (graph). + - NIT-D: `a_heard_curve_on_a_lane_the_successor_no_longer_automates_is_forgotten` (graph). + - NIT-E: `crates/host-core/tests/route_mute.rs` + `a_successor_of_a_plan_that_never_rendered_takes_its_pending_comparison` (Simd8 on AVX2, Simd4, + Scalar; every render after block 0 allocation- and free-free). +- **Red checks (temporary mutations, reverted).** Control plane ignores the follow flag -> both + host gates red (`(flags, count)` `(1, 0)` against `(2, 1)`). The join flags on the predecessor's + kind alone -> the host-core unit gate red ("seven -> seven") and the capi gate red (seven -> + seven reports the fallback). `reduce_gated_into` sums every active route over the whole block + (the verifier's `MAJ1_into_only`) -> MINOR-C red. The `*curve = false` line removed (the + verifier's `unautomated_curve_kept`) -> NIT-D red. `adopt_predecessor` skips the predecessor's + pending comparisons -> NIT-E red ("the send reopens from the block C first renders"). diff --git a/.github/ISSUE_SPECS/TBD51-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md b/.github/ISSUE_SPECS/TBD51-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md new file mode 100644 index 000000000..24b47d6d8 --- /dev/null +++ b/.github/ISSUE_SPECS/TBD51-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md @@ -0,0 +1,59 @@ +# Carry a following route's column moves exactly into a six-word successor + +Placeholder from batch 18 (#1652 U2a fold 2, MAJOR-A; root, 2026-10-17: "exact form not now", +phase 2). Root files it and gives it a number. Stateless: everything needed is below. + +## Problem + +A send into a submix whose source strip has a mute entry is a *following route*: a seven-word +route cell and render state with one ramp per source column (#1652 D1, root ruling R18-5). A +rebuild that removes the strip's last mute entry makes that send a six-word route again +(#1347's cell, one ramp for all four coefficients). The carry by route ID (#1652 D3) then moves +two column ramps into one ramp, and render can re-time a move that is in flight at the swap: + +- (a) `finish_six_word_carry` (`crates/graph/src/runtime.rs`): when the join's record, composed + with the curve the predecessor heard, differs from it, the record's open columns ramp over the + session mute length `J`, not over the record's own length `R`; +- (b) `ColumnRamps::one_ramp` (same file): two column moves in different states fold into one + ramp from where the route is to the composite target over the longer remaining length, so the + shorter move is stretched. + +Today (#1652 U2a fold 2) such a rebuild completes as `CandidateOutcome::TransitionFallback`: host +core's send join (`resolve_route_carry`, `crates/host-core/src/prepare.rs`) sets +`PlanStateInventory::carries_a_following_route_into_a_six_word_route` when a move's predecessor +row follows and its successor row is six-word, and the control plane's rebuild +(`crates/control-plane/src/control.rs`) marks the candidate. The flag is conservative: it is set +for every such rebuild, also when no column moves at the swap. + +## Exact form + +Keep a seven-word successor while the predecessor follows: the successor's route stays a +following route whose source has no automated lane (`automated == [false; 2]`, so the gate is +`terms` alone, and every column keeps its own ramp), until the next rebuild, which prepares it +six-word. Costs and limits: + +- Validation refuses a following source with no automated lanes today + (`GraphFollowRouteSource::new` refuses "no automated lane"); this issue allows it for a carried + route only. +- About 470 B per such route (the seven-word owner, cell, binding and producer against the + six-word ones), charged by `size_of`, until the next rebuild. +- #1653's live commit writes the seven-word record to such a route. + +## Gates + +1. A rebuild that removes the mute entry while one send column is mid live move: the move ends at + its own length, bit for bit the never-swapped twin's column (both hosts, the C ABI and the + browser at the native width; host-core at `Simd8` under avx2, `Simd4` and the scalar oracle). +2. Two column moves of different lengths at the swap each end at their own length. +3. The same rebuild's watermark reports `OUTCOME_EXACT`. +4. The rebuild after that one prepares the route six-word and carries it (seven -> six with no + automated lane) with no re-timing. +5. Realtime: the swap block allocates and frees nothing. + +## Deletes + +The conservative flag: `PlanStateInventory::carries_a_following_route_into_a_six_word_route`, its +field and its join line, the control plane's `follow_into_six_word` term, and the gates +`a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback` (capi), +`a_rebuild_that_drops_a_following_route_to_six_words_reports_the_fallback_alike` (host-web) and +`the_join_flags_a_following_route_carried_into_a_six_word_one` (host-core), replaced by gate 3. diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index afc57c8bd..cf231077a 100644 --- a/crates/capi/include/miso_engine_v1.h +++ b/crates/capi/include/miso_engine_v1.h @@ -181,7 +181,9 @@ 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; #1711 removes it). The host + * set when the move was not stretched; #1711 removes it), or a rebuild that carries a send + * following a stored mute into one that follows none (conservative: it can be set when no send + * moves at the swap; TBD51 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 eb43982b9..ff41b8df7 100644 --- a/crates/capi/src/abi.rs +++ b/crates/capi/src/abi.rs @@ -94,7 +94,9 @@ 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; #1711 removes it). +/// ramp (conservative: it can be set when the move was not stretched; #1711 removes it), or a +/// rebuild that carries a send following a stored mute into one that follows none (conservative: +/// it can be set when no send moves at the swap; TBD51 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 d5b2d790d..5900997a0 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -11060,6 +11060,86 @@ fn a_following_send_leaks_nothing_of_a_stored_mute() { ); } +/// #1652 U2a fold 2 (MAJOR-A, conservative; TBD51 deletes the flag). A rebuild whose send join +/// carries a following route into a six-word one (it removes `eq0`'s mute entry while `eq0-b` +/// follows it) completes as the transition fallback: the C ABI watermark that covers it reads +/// `OUTCOME_TRANSITION_FALLBACK`, count 1. The removal is sent at block 8 (sample 1,024), while the +/// send's columns ramp toward the mute the curve stepped on at 1,000 (480 samples). A rebuild that +/// keeps the entry (the following route stays following, seven -> seven) and a rebuild of a +/// session with no following route (no mute entry) complete as `OUTCOME_EXACT`, count 0. +/// +/// Test value: red if the control plane does not mark a seven -> six rebuild (the six-word +/// successor's re-timed column move then completes exact), or if it marks a rebuild whose +/// following routes stay following, or one with no following route (the flag must stay narrow). +#[test] +fn a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback() { + let remove = SessionEdit::RemoveAutomation { + automation_id: StableId::parse("eq0-mute").expect("automation ID"), + }; + let fallback = crate::OUTCOME_TRANSITION_FALLBACK; + let exact = crate::OUTCOME_EXACT; + for (label, entry, remove_entry, expected) in [ + ("seven -> six, a column mid-move", true, true, fallback), + ("seven -> seven", true, false, exact), + ("no following route", false, false, exact), + ] { + let document = follow_document(entry, false); + let model = parse_session_json(&document).expect("model"); + let edits = if remove_entry { + vec![remove.clone()] + } else { + // A muted copy of `eq0` with its own route to the output: a rebuild that leaves + // `eq0-b` and `eq0`'s mute entry as they are. + let mut track = model.tracks[0].clone(); + track.id = StableId::parse("added").expect("track ID"); + (track.fader.left_mute, track.fader.right_mute) = (true, true); + let mut route = model + .routes + .iter() + .find(|route| route.id.as_str() == "eq0-main") + .expect("the direct route") + .clone(); + route.id = StableId::parse("added-main").expect("route ID"); + route.source = RouteSource::Track { + track_id: track.id.clone(), + tap: session::SendTap::PostPan, + }; + vec![ + SessionEdit::UpsertTrack { track }, + SessionEdit::UpsertRoute { route }, + ] + }; + let mut rig = Rig::new(&document); + for _ in 0..8 { + rig.step(); + } + let bytes = rig.transaction(&edits); + let (result, response) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK, "{label}"); + assert_eq!( + edit_path_of(&response), + protocol::EditPath::Rebuild, + "{label}" + ); + let revision = rig.summary().0; + for _ in 0..4 { + rig.step(); + } + assert_eq!(rig.summary().3, 0, "{label}: the successor is adopted"); + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + assert_eq!( + ( + watermark.revision, + watermark.outcome_flags, + watermark.transition_fallback_count + ), + (revision, expected, u64::from(expected == fallback)), + "{label}: the rebuild's revision, outcome flags and transition-fallback count" + ); + } +} + /// #1652c: an edit that lowers a following route (a route into a submix whose source strip has a /// mute entry) is a rebuild until #1653 gives the live commit the seven-word record: a send gain /// edit on `eq0-b`, and a hand mute of `eq0` (which changes `eq0-b`'s follow gate). Each commits diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index e51c15754..aabb7bf68 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -2516,6 +2516,9 @@ where revision_gate, master_track: _, } = prepared_runtime; + // #1652 U2a fold 2 (MAJOR-A; TBD51 deletes it): read before the inventory moves into the + // candidate's provider epoch. + let follow_into_six_word = inventory.carries_a_following_route_into_a_six_word_route(); let mut candidate_provider = ProviderEpoch::candidate( sources, inventory, @@ -2604,6 +2607,14 @@ where // the window with the commit. A declared discontinuity carries nothing. The test is the // fader row alone (#1651 T2, root ruling R18-4): a mute cell's adoption never stretches a // live move, so a successor that automates only a mute row completes `Exact`. + // + // #1652 U2a fold 2 (MAJOR-A; TBD51 deletes this): a successor whose send join carries a + // following route into a six-word route (the rebuild removes a strip's last mute entry + // while a following send of it exists) may re-time a carried column's move, which render + // cannot report: one ramp of the six-word route takes the session mute length for a join + // record, and folds two columns' moves into one. Such a rebuild completes as the transition + // fallback, whether or not a column moves at the swap: conservative, it over-reports. It + // is not a level move, so it does not open the level-move window. let (outcome, rebuild_level) = match &kind { Rebuild::Transaction(prepared) => { let session = prepared.get().prospective_session(); @@ -2622,7 +2633,8 @@ where self.publisher.render_clock(), ) }); - let outcome = if automates && in_flight { + let level_fallback = automates && in_flight; + let outcome = if level_fallback || follow_into_six_word { engine::realtime::CandidateOutcome::TransitionFallback } else { engine::realtime::CandidateOutcome::Exact @@ -2630,11 +2642,11 @@ where let moved = 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); + // A rebuild flagged for a level move 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 = + level_fallback.then(|| controls::LiveRamps::for_session(next).fader_samples); (outcome, moved.max(landing)) } Rebuild::Discontinuity(_) => (engine::realtime::CandidateOutcome::Exact, None), diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index a083ae9d6..81f4a9725 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -5522,8 +5522,10 @@ impl PreparedPlanExecutor for GraphExecutor { // comparisons its carry left. Take them first, as that block would have (under every // snapshot and its held jump lengths, as the carry applies pending values), so this // plan carries the state the predecessor's next block would have rendered from. (Not - // gated in graph: two swaps with a following route need host-core's attach, #1652c; the - // host-core carry tranche gates it.) + // gated in graph: two swaps with a following route need host-core's attach, #1652c. + // Reachable when a render call adopts a plan and is then refused before it renders; + // gated by host-core's `route_mute` + // `a_successor_of_a_plan_that_never_rendered_takes_its_pending_comparison`.) if let Some(checks) = predecessor.route_checks.as_deref_mut() && core::mem::take(&mut checks.pending) { diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index a7eccb5fb..bbf569697 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -18277,6 +18277,73 @@ mod follow_route_op_tests { ); } + /// U2a fold 2, NIT-D (seven -> seven -> seven). P1 automates both source lanes and has + /// heard its left curve on (the left column zeroed); P2 automates only the right lane, so + /// its carried first block forgets the left lane's heard curve (no curve gates a lane the + /// plan does not automate, so the left column reopens over the mute length); P3 automates + /// the left lane again, its curve off, and the join writes a record (`MOVED` over + /// `RECORD_RAMP`) for P3's first block. That record composes with the curve P2 heard, + /// which is off on both lanes, so both columns move together over `RECORD_RAMP` from the + /// open coefficients P2 settled on, and the placed curve then changes no bit. + /// + /// Test value: red if a carried first block keeps a heard curve on a lane its plan no + /// longer automates (the `else if !automated` clear): the stale `on` then reaches P3, + /// whose record composes with it and zeroes the left column over `RECORD_RAMP`, and the + /// placed curve reopens it over the mute length. + #[test] + fn a_heard_curve_on_a_lane_the_successor_no_longer_automates_is_forgotten() { + let on = steps(&[0], &[1.0], 1 << 20); + let off = steps(&[0], &[0.0], 1 << 20); + let mut p1 = Fixture::new([Some(&on), Some(&off)], 0, OPEN); + let (mut left, mut right) = input(BEFORE); + assert!(p1.block(FIRST, None, &mut left, &mut right)); + assert_eq!( + p1.route.ramps, + ColumnRamps::settled(gated(OPEN, [true, false])), + "P1 holds the left column zeroed" + ); + + let mut p2 = Fixture::new([None, Some(&off)], 0, OPEN); + assert!(!carry_route( + CarriedRoute::Seven(&mut p2.route), + CarriedRoute::Seven(&mut p1.route), + None, + )); + let (mut left, mut right) = input(AFTER); + assert!(p2.block(FIRST + BEFORE as u64, None, &mut left, &mut right)); + assert!( + p2.route.ramps.is_settled(), + "P2's reopening ended inside its block" + ); + let open = gated(OPEN, [false; 2]); + assert_eq!(p2.route.ramps.target(), open, "P2 settles open"); + + let mut p3 = Fixture::new([Some(&off), Some(&off)], 0, OPEN); + assert!(!carry_route( + CarriedRoute::Seven(&mut p3.route), + CarriedRoute::Seven(&mut p2.route), + None, + )); + p3.write(MOVED, RECORD_RAMP); + let (mut left, mut right) = input(AFTER); + assert!(p3.block(FIRST + (BEFORE + AFTER) as u64, None, &mut left, &mut right)); + let record = IndexedRamp::new(open, gated(MOVED, [false; 2]), RECORD_RAMP); + let (mut want_left, mut want_right) = input(AFTER); + route_mix_ramp_block::(&mut want_left, &mut want_right, &record, 0); + assert_eq!( + bits((&left, &right)), + bits((&want_left, &want_right)), + "P3's record moves both columns over its own ramp" + ); + assert_eq!( + [ + p3.route.ramps.ramps[0].length, + p3.route.ramps.ramps[1].length + ], + [RECORD_RAMP; 2] + ); + } + /// Gate 4 (b) (seven -> six, the rebuild removes the mute entry): a following route the /// curve holds muted (settled and inactive) is carried into a six-word successor whose /// prepared target is open. The successor's first block reopens the send from silence @@ -18332,8 +18399,8 @@ mod follow_route_op_tests { /// per-column ramps (R18-5: MAJOR-2, MINOR-1) and the carried record's order (MAJOR-3). mod fold { use super::super::super::{ - CompensationDelay, DisjointArena, GraphRuntimeSplitPairProcessor, NodeKind, - ROUTE_DESTINATION, RouteActivity, RouteInput, RuntimeOp, TrackDelayLine, + CompensationDelay, DisjointArena, GraphRuntimeSplitPairProcessor, HostMaster, + NodeKind, ROUTE_DESTINATION, RouteActivity, RouteInput, RuntimeOp, TrackDelayLine, execute_op, }; use super::super::{LENGTHS, Timeline}; @@ -18360,12 +18427,17 @@ mod follow_route_op_tests { destination: RuntimeOp, activity: RouteActivity, gate: engine::realtime::RevisionGate, + /// Whether the destination is the session Output, reducing into the host's + /// planes ([`HostMaster`], `reduce_gated_into`) instead of its arena buffer + /// (`reduce_gated`). U2a fold 2, MINOR-C. + host: bool, } impl Graph { - /// `fixture`'s route as unit 0, reading `SOURCE`; the destination reads `inputs`. - /// The route's activity bit starts set, as a prepared open route's does. - fn new(fixture: Fixture, inputs: &[u32], position: u32) -> Self { + /// `fixture`'s route as unit 0, reading `SOURCE`; the destination reads `inputs` + /// and writes the host's planes when `host`. The route's activity bit starts set, + /// as a prepared open route's does. + fn new(fixture: Fixture, inputs: &[u32], position: u32, host: bool) -> Self { let Fixture { mut route, gate, .. } = fixture; @@ -18390,6 +18462,7 @@ mod follow_route_op_tests { from: Box::new([0]), }, gate, + host, } } @@ -18425,12 +18498,18 @@ mod follow_route_op_tests { Some(&timeline), ); let snapshot = self.gate.snapshot(); + // The host's planes start as stale words: the Output op writes every one. + let (mut host_left, mut host_right) = (vec![9.0; frames], vec![9.0; frames]); for (op, unit) in [(&mut self.route, 0), (&mut self.destination, 1)] { let (delays, lines, pairs): ( &mut [CompensationDelay], &mut [TrackDelayLine], &mut [Box], ) = (&mut [], &mut [], &mut []); + let host = (self.host && unit == 1).then(|| { + HostMaster::new(&mut host_left, &mut host_right, frames) + .expect("host planes") + }); execute_op( op, &mut arena, @@ -18439,16 +18518,20 @@ mod follow_route_op_tests { pairs, 0, snapshot, - None, + host, Some((&mut self.activity, unit)), &time, ) .expect("the block renders"); } - ( - arena.read(0, DESTINATION).to_vec(), - arena.read(1, DESTINATION).to_vec(), - ) + if self.host { + (host_left, host_right) + } else { + ( + arena.read(0, DESTINATION).to_vec(), + arena.read(1, DESTINATION).to_vec(), + ) + } } } @@ -18472,19 +18555,34 @@ mod follow_route_op_tests { /// input (`+0.0` against `-0.0`), first beside a `-0.0` input, and second after one /// (with positive feeds, `+0.0` after `-0.0`). /// + /// Every case runs with the destination as a submix (its arena buffer, + /// `reduce_gated`) and as the session Output (the host's planes, `reduce_gated_into`, + /// U2a fold 2 MINOR-C). + /// /// Test value: red if the destination sums an opening route over the whole block (the - /// signed-zero leak the brief's MAJOR-1 names), or if the route op's activity bit - /// is not written each block (mutation M2: the inactive block reads the stale words). + /// signed-zero leak the brief's MAJOR-1 names) in either reduction, `reduce_gated_into` + /// alone included, or if the route op's activity bit is not written each block + /// (mutation M2: the inactive block reads the stale words). #[test] fn an_opening_route_is_left_out_of_its_destination_before_its_offset() { let frames = 128_usize; // (case, the destination's inputs, the route's position, negative feeds, the // other input's word, the destination's word while the route is left out) - for (case, inputs, position, negative_feed, other_word, idle_word) in [ + let cases = [ ("sole", &[ROUTED][..], 0, true, 0.0, 0.0), ("first", &[ROUTED, OTHER][..], 0, true, -0.0, 0.0), ("second", &[OTHER, ROUTED][..], 1, false, -0.0, -0.0), - ] { + ]; + for (host, (case, inputs, position, negative_feed, other_word, idle_word)) in + [false, true] + .into_iter() + .flat_map(|host| cases.iter().map(move |case| (host, *case))) + { + let case = if host { + format!("{case} into the session Output") + } else { + format!("{case} into a submix") + }; for o in [1_usize, 63, 64, 127] { let open = FIRST + frames as u64 + o as u64; let curve = steps(&[0, open], &[1.0, 0.0], 1 << 20); @@ -18504,7 +18602,7 @@ mod follow_route_op_tests { let other = (vec![other_word; frames], vec![other_word; frames]); let fixture = || Fixture::new([Some(&curve), Some(&curve)], 0, OPEN); - let mut whole = Graph::new(fixture(), inputs, position); + let mut whole = Graph::new(fixture(), inputs, position, host); let idle = whole.block(FIRST, (&feed.0, &feed.1), (&other.0, &other.1)); let idle_expected = vec![idle_word; frames]; assert_eq!( @@ -18520,7 +18618,7 @@ mod follow_route_op_tests { ); assert_eq!(whole.activity.from[0], o as u32, "{case}: the offset"); - let mut split = Graph::new(fixture(), inputs, position); + let mut split = Graph::new(fixture(), inputs, position, host); split.block(FIRST, (&feed.0, &feed.1), (&other.0, &other.1)); let first = split.block( FIRST + frames as u64, diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index b9c095962..1eca857ae 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -716,6 +716,27 @@ impl PreparedHost { } } +impl PlanStateInventory { + /// Whether this plan's send join carries a route that follows an automated source mute in + /// the plan it was prepared to displace into a route that follows none in this plan (a + /// seven-word route into a six-word one, #1652 D3): the rebuild that removes a strip's last + /// mute entry while a following send of it exists. `false` for a plan prepared without a + /// predecessor and for a declared discontinuity, which carries nothing. + /// + /// **Conservative (#1652 U2a fold 2, MAJOR-A; TBD51 removes it).** A six-word successor holds + /// one ramp, so render may re-time a carried column's move: a join record composed with the + /// heard curve reopens the route over the session mute length `J` instead of the record's own + /// length, and two columns' moves fold into one ramp over the longer remaining length. The + /// control plane completes such a rebuild as [`engine::realtime::CandidateOutcome::TransitionFallback`] + /// so the watermark never reports it exact. It over-reports: a rebuild that removes a strip's + /// last mute automation while a following send of it exists is flagged whether or not a + /// column is moving at the swap. + #[must_use] + pub fn carries_a_following_route_into_a_six_word_route(&self) -> bool { + self.follow_into_six_word + } +} + /// Set `host`'s source-read offset (#1396 deliverable 2): the plan's /// ([`PreparedRenderPlan::set_source_read_offset`]) and its inventory row's together, so a /// successor prepared from the inventory inherits it. Call it before the plan is published. The @@ -780,6 +801,11 @@ pub struct PlanStateInventory { strip_delays: Box<[graph::GraphStripDelayLine]>, /// One row per live send of the plan (#1284 D2), sorted by route ID. live_routes: Box<[graph::GraphLiveRoute]>, + /// Whether this plan's send join carries a following route of the plan it was prepared to + /// displace into a six-word route of its own (#1652 U2a fold 2, MAJOR-A; see + /// [`Self::carries_a_following_route_into_a_six_word_route`]). `false` for a plan prepared + /// without a predecessor. + follow_into_six_word: bool, /// The strips this plan restarts relative to the plan it was prepared to displace (#1277 D6): /// present in both, with at least one owner that does not carry. Sorted by strip ID. Empty /// for a plan prepared without a predecessor. @@ -1070,6 +1096,7 @@ impl PlanStateInventory { delay_lines: Box::default(), strip_delays: Box::default(), live_routes: Box::default(), + follow_into_six_word: false, restarted: Box::default(), input_floors: Box::default(), raised_arrivals: Box::default(), @@ -1102,6 +1129,7 @@ impl PlanStateInventory { delay_lines: Box::default(), strip_delays: Box::default(), live_routes: Box::default(), + follow_into_six_word: false, restarted: Box::default(), input_floors: self.input_floors.clone(), raised_arrivals: self.raised_arrivals.clone(), @@ -3633,6 +3661,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( delay_lines: delay_lines.into_boxed_slice(), strip_delays: strip_delays.into_boxed_slice(), live_routes: live_routes.into_boxed_slice(), + follow_into_six_word: route_carry.follow_into_six_word, restarted: restarted.into_boxed_slice(), input_floors, raised_arrivals, @@ -5805,6 +5834,10 @@ struct RouteCarry<'a> { /// The carried sends whose live values differ, by route ID, with the record to write to the /// successor's cell. retargets: Vec<(&'a str, SendRecord)>, + /// Whether some move carries a following predecessor route into a six-word successor route + /// (#1652 U2a fold 2, MAJOR-A): the rebuild then completes as the transition fallback + /// ([`PlanStateInventory::carries_a_following_route_into_a_six_word_route`]). + follow_into_six_word: bool, } /// The route half of the send join (#1284 D2): a send carries when both plans hold a live route of @@ -5821,11 +5854,17 @@ fn resolve_route_carry<'a>( }; let mut carry = RouteCarry::default(); for join in joins { - let (Some((before, follows)), Some((after, _))) = + let (Some((before, follows)), Some((after, successor_follows))) = (row(predecessor, join.id), row(own, join.id)) else { continue; }; + // #1652 U2a fold 2 (MAJOR-A; TBD51 deletes it): a following route carried into a + // six-word one holds one ramp, so render may re-time a column's move (a record composed + // with the heard curve reopens over `J`, not its own length; two columns' moves fold into + // one ramp over the longer remaining length). The join cannot tell whether a move is in + // flight, so it flags every such carry: the conservative rule. + carry.follow_into_six_word |= follows && !successor_follows; carry.moves.push(graph::GraphRouteMove { successor: after, predecessor: before, @@ -6619,6 +6658,46 @@ mod tests { ] ); assert_eq!(carry.retargets, vec![("a-main", record)]); + assert!( + !carry.follow_into_six_word, + "no route follows in either plan" + ); + } + + /// #1652 U2a fold 2 (MAJOR-A): the join flags a carry of a following predecessor route into a + /// six-word successor route, and only that kind pair: six -> six, six -> seven and seven -> + /// seven moves leave the flag clear, and a following route present in one plan only moves + /// nothing and flags nothing. + /// + /// Test value: red if the flag reads the predecessor's kind alone (seven -> seven flagged: + /// a rebuild that keeps the mute entry would over-report), the successor's alone, or ignores + /// both (the seven -> six re-timing then completes exact). + #[test] + fn the_join_flags_a_following_route_carried_into_a_six_word_one() { + use super::{RouteJoin, resolve_route_carry}; + let live = |route: &str, unit, following| graph::GraphLiveRoute { + route_id: route.into(), + unit, + following, + }; + let join = |id| RouteJoin { id, retarget: None }; + // (case, predecessor following, successor following, flagged) + for (case, before, after, flagged) in [ + ("six -> six", false, false, false), + ("six -> seven", false, true, false), + ("seven -> seven", true, true, false), + ("seven -> six", true, false, true), + ] { + let predecessor = [live("a-main", 1, false), live("b-main", 2, before)]; + let own = [live("a-main", 3, false), live("b-main", 4, after)]; + let carry = resolve_route_carry(&predecessor, &own, &[join("a-main"), join("b-main")]); + assert_eq!(carry.moves.len(), 2, "{case}: both sends carry"); + assert_eq!(carry.follow_into_six_word, flagged, "{case}"); + } + // A following route the successor lacks is not a move. + let predecessor = [live("a-main", 1, true)]; + let carry = resolve_route_carry(&predecessor, &[], &[join("a-main")]); + assert!(carry.moves.is_empty() && !carry.follow_into_six_word); } /// #1283 D1: lines join by edge ID and endpoints, never by index. A line moves when its @@ -6677,6 +6756,7 @@ mod tests { delay_lines: Box::default(), strip_delays: Box::default(), live_routes: Box::default(), + follow_into_six_word: false, restarted: Box::default(), input_floors: Box::default(), raised_arrivals: Box::default(), diff --git a/crates/host-core/tests/route_mute.rs b/crates/host-core/tests/route_mute.rs index ccb5e7e84..e8cb3fb85 100644 --- a/crates/host-core/tests/route_mute.rs +++ b/crates/host-core/tests/route_mute.rs @@ -2132,6 +2132,151 @@ fn a_rebuild_that_removes_the_mute_entry_reopens_the_send() { } } +/// #1652 U2a fold 2, NIT-E (fold 1's MINOR-2, two swaps with a pending comparison; every bank +/// width and the scalar oracle). As gate 4(b), predecessor `A`'s mute entry holds `t` muted and +/// `B` removes it, so `B`'s six-word `t-b` leaves a comparison for its first block. Render adopts +/// `B` at block 6 in a call that fails before it renders (a time discontinuity: the host asked for +/// another sample), so `B` is displaced before its first block by `C`, the same session prepared +/// from `B`'s inventory, which carries `t-b` six -> six and is adopted by the next call. `C` takes +/// the state `B`'s first block would have rendered from: the send reopens over the session mute +/// length from that block, and from the next boundary it is `C` prepared fresh, bit for bit. +/// Neither swap allocates on render. +/// +/// Test value: red if adopting a predecessor that never rendered drops the comparisons its own +/// carry left (`C` then takes the adopted mute, and the send stays silent for good). +#[cfg(feature = "test-support")] +#[test] +fn a_successor_of_a_plan_that_never_rendered_takes_its_pending_comparison() { + use engine::realtime::{PlanAdoption, PlanarBufferMut, RenderError, RenderIo}; + use follow::*; + use successor::{BLOCKS, QUANTUM, SWAP_BLOCK}; + let pcm = successor::Feed::new("t", 13); + let b = model(QUANTUM as u32, SendTap::PreFader, false, true); + let mut a = b.clone(); + a.automation = vec![mute_entry(1.0, &[])]; + let request = host_core::HostLiveControlRequest::default(); + let caps = successor::caps(); + for backend in follow::backends() { + let what = format!("{backend:?}"); + let prepare = + |model: &SessionModel, + base: Option<(&host_core::PlanStateInventory, &SessionModel, u64)>| { + let session = successor::Session::compile(model.clone()); + match base { + None => host_core::test_only_prepare_host_runtime_with_live_controls_on( + &session.compiled, + &caps, + &request, + backend, + ), + Some((inventory, committed, revision)) => { + let committed = successor::Session::compile(committed.clone()).model; + host_core::test_only_prepare_host_runtime_with_live_controls_successor_on( + &session.compiled, + &caps, + &request, + host_core::SuccessorBase::new(inventory, &committed, revision), + backend, + ) + } + } + .unwrap_or_else(|failure| panic!("{what}: prepare: {failure:?}")) + .0 + }; + let first = prepare(&a, None); + let rate = first.report.sample_rate_hz; + let PreparedHost { + mut plan, + sources: mut a_sources, + inventory: a_inventory, + .. + } = first; + let gate = plan.take_revision_gate().expect("the plan's gate"); + let (mut publisher, mut owner, mut retirer) = successor::exchange(plan); + pcm.submit(&mut a_sources, rate, 0); + let mut blocks = Vec::with_capacity(BLOCKS); + for block in 0..SWAP_BLOCK { + pcm.submit(&mut a_sources, rate, block + 1); + blocks.push(successor::render(&mut owner)); + } + let mut publish = |prepared: PreparedHost, revision: u64| { + let PreparedHost { + mut plan, + sources, + inventory, + .. + } = prepared; + plan.take_revision_gate() + .expect("the successor's gate") + .publish(revision); + publisher + .reserve_replacement(plan, PlanAdoption::Next) + .unwrap_or_else(|_| panic!("{what}: reserve the successor")) + .commit(); + (sources, inventory) + }; + let revision = gate.last_published() + 1; + let mut b_prepared = prepare(&b, Some((&a_inventory, &a, revision))); + b_prepared.sources.adopt_persisting(&mut a_sources); + let (mut b_sources, b_inventory) = publish(b_prepared, revision); + // Render adopts B at the block boundary, then refuses the call: B renders nothing. + let mut output = [f32::NAN; QUANTUM * 2]; + let wrong = owner.next_absolute_sample() + 1; + let refused = owner.render_contiguous( + RenderIo { + output: PlanarBufferMut::try_new(&mut output, 2, QUANTUM, QUANTUM).expect("output"), + }, + wrong, + ); + assert!( + matches!(refused, Err(RenderError::TimeDiscontinuity { .. })), + "{what}: the call is refused after the boundary" + ); + drop(retirer.try_reclaim().expect("A retired at B's adoption")); + let mut c_prepared = prepare(&b, Some((&b_inventory, &b, revision + 1))); + c_prepared.sources.adopt_persisting(&mut b_sources); + let (mut c_sources, _) = publish(c_prepared, revision + 1); + for block in SWAP_BLOCK..BLOCKS { + if block + 1 < BLOCKS { + pcm.submit(&mut c_sources, rate, block + 1); + } + blocks.push(successor::render(&mut owner)); + } + assert_eq!( + blocks[SWAP_BLOCK].swap, + engine::realtime::SwapOutcome::Applied, + "{what}: C is adopted at the swap block" + ); + assert_quiet(&blocks, &what); + let send = plane(&blocks, 1); + let at = SWAP_BLOCK * QUANTUM; + assert!( + send[..at].iter().all(|sample| *sample == 0.0), + "{what}: the send is follow-muted before the swaps" + ); + let ramp = &send[at..at + M as usize]; + assert!( + ramp.iter().any(|sample| *sample != 0.0), + "{what}: the send reopens from the block C first renders" + ); + assert!( + ramp.iter() + .zip(&pcm.planes[1][at..]) + .any(|(sample, source)| sample.to_bits() != source.to_bits()), + "{what}: the send ramps open, it does not step" + ); + let open = next_boundary(at + M as usize, QUANTUM); + let fresh = + successor::reference_run(&successor::Session::compile(b.clone()), &[&pcm], backend); + let fresh = plane(&fresh, 1); + assert_bits_equal( + &send[open..BLOCKS * QUANTUM], + &fresh[open..BLOCKS * QUANTUM], + &format!("{what}: open after the ramp"), + ); + } +} + /// #1652 gate 4, the combined transaction (coordinator obligation 3). One rebuild at block 6 /// changes the send's gain and adds a mute entry that holds `t` muted from the start: the strip /// and the send both adopt the curve's mute at the swap, over the same session mute length, so the diff --git a/crates/lane/src/kernels.rs b/crates/lane/src/kernels.rs index d116b9b6b..a044b3f5e 100644 --- a/crates/lane/src/kernels.rs +++ b/crates/lane/src/kernels.rs @@ -1845,8 +1845,14 @@ pub fn route_mix_column_ramps_block( let either = core::cmp::max(ramping[0], ramping[1]); let (left_both, left_rest) = left.split_at_mut(both); let (right_both, right_rest) = right.split_at_mut(both); - let (left_one, left_settled) = left_rest.split_at_mut(either - both); - let (right_one, right_settled) = right_rest.split_at_mut(either - both); + // `either - both <= count - both` is each rest's length, but the bound is stated so the split + // has no panic path in any instantiation (the wasm `f32x4` one kept `mid > len`, U2a fold 2). + let one = core::cmp::min( + either - both, + core::cmp::min(left_rest.len(), right_rest.len()), + ); + let (left_one, left_settled) = left_rest.split_at_mut(one); + let (right_one, right_settled) = right_rest.split_at_mut(one); column_ramps_part::(left_both, right_both, ramps, positions, 0); if ramping[0] > ramping[1] { column_ramps_part::(left_one, right_one, ramps, positions, both); diff --git a/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index cfdd51cb8..15526d738 100644 --- a/docs/C_ABI_V1_QUALIFICATION.md +++ b/docs/C_ABI_V1_QUALIFICATION.md @@ -151,7 +151,9 @@ 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; #1711 removes it; root ruling B17-R14). The frozen exported +when the move was not stretched; #1711 removes it; root ruling B17-R14), and a rebuild that carries +a send following a stored mute into one that follows none (conservative: it can be set when no +send moves at the swap; TBD51 removes it; #1652 U2a fold 2). 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 bec903f93..0c012e713 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -5313,3 +5313,102 @@ fn a_following_send_leaks_nothing_of_a_stored_mute_on_both_hosts() { "the strip and its send play before the mute" ); } + +/// #1652 U2a fold 2 (MAJOR-A, conservative; TBD51 deletes the flag) on both hosts. A rebuild +/// whose send join carries a following route into a six-word one (it removes `t1`'s mute entry +/// while `t1-b` follows it, sent at block 8 while the send's columns ramp toward the mute the +/// curve stepped on at 1,000) completes as the transition fallback; a rebuild that keeps the +/// entry (the unmute moved to 3,000: `t1-b` stays following) and a rebuild of a session with no +/// following route (a fader ride added to `t1`, no move in flight) complete 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 render the same bits. +/// +/// Test value: red if the shared control plane does not mark a seven -> six rebuild, marks a +/// rebuild whose following route stays following or one with no following route, or if either +/// host decides the outcome by a rule of its own. +#[test] +fn a_rebuild_that_drops_a_following_route_to_six_words_reports_the_fallback_alike() { + let step = |start: u64, end: u64, from: f32, to: f32| session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: session::ParameterUnit::Linear, + }; + let later_unmute = SessionEdit::UpsertAutomation { + automation: session::Automation { + id: id("t1-mute"), + target: address( + Owner::Strip(id(T1.id)), + Section::Builtins, + MUTE, + ParameterChannel::Both, + ), + segments: vec![step(0, 1_000, 0.0, 1.0), step(1_000, 3_000, 1.0, 0.0)], + offset: session::AutomationOffset::default(), + }, + }; + let ride = 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: -12.0, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + }, + }; + let remove = SessionEdit::RemoveAutomation { + automation_id: id("t1-mute"), + }; + for (label, entry, edit, expected) in [ + ( + "seven -> six, a column mid-move", + true, + remove, + capi::OUTCOME_TRANSITION_FALLBACK, + ), + ("seven -> seven", true, later_unmute, capi::OUTCOME_EXACT), + ("no following route", false, ride, capi::OUTCOME_EXACT), + ] { + let model = follow_model(entry, false); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + same_blocks(&mut browser, &mut c_abi, 0..8, label); + 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, "{label}: one verdict"); + assert_eq!(b_reply, c_reply, "{label}: one reply"); + assert_eq!( + b, + Verdict::Applied { + path: EditPath::Rebuild, + revision: model.revision + 1 + }, + "{label}: the edit rebuilds" + ); + same_blocks(&mut browser, &mut c_abi, 8..12, label); + let watermark = c_abi.watermark(); + assert_eq!( + status_watermark(&browser), + watermark, + "{label}: the browser status carries the C ABI's watermark" + ); + assert_eq!( + (watermark.0, watermark.2), + (model.revision + 1, expected), + "{label}: the rebuild's revision and outcome 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 273b3f9b7..a6599e924 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 @@ -781,7 +781,8 @@ export interface MisoStatus { /// 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; #1711 removes - /// it). + /// it), and a rebuild that carries a send following a stored mute into one that follows none + /// (conservative: it can be set when no send moves at the swap; TBD51 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 273b3f9b7..a6599e924 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -781,7 +781,8 @@ export interface MisoStatus { /// 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; #1711 removes - /// it). + /// it), and a rebuild that carries a send following a stored mute into one that follows none + /// (conservative: it can be set when no send moves at the swap; TBD51 removes it). /// @internal readonly appliedOutcome: bigint; /// The module's memory size now: at least the boot size (`MisoAudioWorkletHost.memoryBytes`), From 283c480d41ea8343827eb83c016fafa5574d7324 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 10:01:01 +0000 Subject: [PATCH 17/23] Write a following send's route cell from the shared commit (#1653) An edit that changes a following route's gate writes its seven-word cell through the live path on both hosts: a route gain, matrix or mute edit, a follows_mute toggle, and a change of the source lane's terms (hand mute, VCA mute, solo) re-send the cell, only when a word's bits change, and no folded follow record is emitted. Before the first write the commit refuses (Internal) a record with no producer of its kind or with automated bits that disagree with the plan; the strip's terms and the route's cell share the transaction's stamp. control-plane keeps the follow route producers. The #1652c rebuild stopgap is replaced; its gate is rewritten in place. In-place commit equals the full path. Co-Authored-By: Claude Opus 5.5 --- ...sends-route-cell-from-the-shared-commit.md | 65 +++++ crates/capi/src/runtime/live_tests.rs | 123 ++++++++- crates/control-plane/src/compile.rs | 3 + crates/control-plane/src/control.rs | 51 +++- crates/graph/src/lib.rs | 13 + crates/host-core/src/lib.rs | 5 +- crates/host-core/src/live_delta.rs | 119 ++++++--- crates/host-core/src/prepare.rs | 55 +++- crates/host-core/tests/in_place_commit.rs | 26 ++ crates/host-core/tests/live_delta.rs | 250 +++++++++++++++++- crates/host-core/tests/live_routes.rs | 197 ++++++++++++++ .../host-core/tests/support/edit_sessions.rs | 56 ++++ hosts/host-web/tests/control_parity.rs | 228 ++++++++++++++++ 13 files changed, 1117 insertions(+), 74 deletions(-) diff --git a/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md b/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md index 3cef435e9..dcc843097 100644 --- a/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md +++ b/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md @@ -196,3 +196,68 @@ Checked against `ad03780ee`. The filed text above stays; where it disagrees, thi - `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, `hosts/host-web/src/tests.rs`. - No render code and no path under `crates/engine/src/realtime/`. + +## Decision record: #1653 (batch 18) + +Replaces #1652c's stopgap (`RebuildOnly(RuledLiveNotBuilt { issue: 1653 })` in `route_records`); +no placeholder remains. No render code, nothing under `crates/engine/src/realtime/`. + +- **D1 (classifier, `crates/host-core/src/live_delta.rs`).** `route_records` lowers a route the + next plan gives the seven-word cell through `follow_route_record_between` (terms from the diff, + `automated` from the structure's `controls::mute_automation`), any other route through + `route_record_between`. `LiveRouteRecord::record` is now `SendRecord` (`Folded` | `Following`), + public and re-exported (`host_core::SendRecord`, with `folded()` / `following()`); the swap + join's `SendRecord` is the same type. The path is `live`. +- **Trigger (fix).** `LevelDiff` gains `terms(before, after)`: `EffectiveDiff::strip_mutes` (dense) + and the held source strip's `EffectiveStrip::mute` (`LocalDiff`, fast path). `lower_routes` + lowers a following route also when its terms change with the gate unchanged + (`follows_mute` off); `LocalDiff::new` holds such a route when its source's mute moved, whatever + its `follows_mute`. A record is written only when a word's bits change + (`follow_route_record_between`'s rule, #1652c). +- **D3.** A `follows_mute` toggle is a route row change and writes the cell only (no mirror). +- **D5 (control plane, `crates/control-plane/src/{compile.rs,control.rs}`).** `StripLanes` keeps + `follow_routes` (the handles' `follow_route_controls`, no longer dropped by `..`). Step 2a + resolves a `Folded` record among the six-word producers and a `Following` one with + `host_core::follow_route_producer_position`, and refuses with `CommandError::Internal`, before + any write, a record with no producer of its kind or whose `automated` bits differ from the + producer's plan. The writes go under the transaction's one stamp, with the strip's terms + (`Mute`) records, so they apply in one block. No `TransitionFallback`. +- **Departure (mechanical, outside the listed paths).** `crates/graph/src/lib.rs`: + `GraphFollowRouteControlProducer` keeps the bound source's `flags.automated` (one + `[bool; 2]` field set in `follow_route_control_pair`) behind `automated()`, which D5's check + reads; the producer is charged by `size_of`, so its charge follows. `TestTransactionSnapshot` + (test observer) gains `follow_route_cells`. `crates/host-core/tests/in_place_commit.rs` gains the + differential case (the `Pair` harness lives there). +- **`crates/host-core/src/prepare.rs` functions touched.** `SendRecord` (public, accessors, doc), + `follows_automated_mute` (now `following_route_lanes(..).is_some()`), new + `following_route_lanes` (the predicate and the `automated` bits in one place). The join and + `route_record_between` are unchanged. +- **Gates (all pass).** + - Gate 1: `crates/capi/src/runtime/live_tests.rs` + `a_follows_mute_toggle_on_a_following_route_is_live`; `hosts/host-web/tests/control_parity.rs` + `a_follows_mute_toggle_on_a_following_route_is_live_on_both_hosts`; + `crates/host-core/tests/live_routes.rs` + `a_follows_mute_toggle_on_a_following_route_settles_on_a_fresh_plan` (Simd8 on AVX2, Simd4, + Scalar oracle; the classifier's records written through the seven-word producers; every width + equals the oracle bit for bit). + - Gate 2: `hosts/host-web/tests/control_parity.rs` + `solo_and_a_vca_mute_compose_with_the_curve_for_a_following_send_on_both_hosts` (solo path + `monitor`, VCA mute `live`; a pre-fader route into the output still plays under the mute). + - Gate 3: `crates/host-core/tests/live_delta.rs` + `a_following_routes_edits_write_its_seven_word_cell_only` (every case also through + `classify_live_staged`, equal to the dense delta); in-place path: + `crates/host-core/tests/in_place_commit.rs` + `a_following_routes_edits_commit_as_the_full_path_does` over + `edit_sessions::following_session` / `following_script` (48 and 96 kHz). + - Superseded rebuild gate `an_edit_of_a_following_route_rebuilds` replaced in place by + `an_edit_of_a_following_route_is_live` (send gain and hand mute: `live`, the committed + document's fresh bits after the ramps). +- **Red checks (temporary mutations, reverted).** Folded record still emitted for a following + route -> gate 3, host-core gate 1, both capi gates and both parity gates red. Terms trigger + missing in both classifiers -> gate 3 red ("one seven-word record", 0). Terms trigger missing in + the fast path only -> the in-place differential red. The commit skips the seven-word write -> + both capi gates and both parity gates red. The pre-write `automated` check removed -> no test + turns red: every record and every producer of a live commit derive `automated` from the same + committed structure (automation is structure, so a value commit cannot change it, and the + newest epoch is prepared from that structure), so the refusal is unreachable through the public + paths; it stays as D5's defence. diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 5900997a0..c84a0c989 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -317,6 +317,18 @@ impl Rig { .collect() } + /// Every following route's seven-word cell write count in the current epoch, in route-ID + /// order (#1653). + fn current_follow_route_cells(&self) -> Vec { + let snapshot = crate::ffi::test_transaction_snapshot(self.session); + snapshot + .follow_route_cells + .iter() + .filter(|(epoch, ..)| *epoch == snapshot.provider_epoch) + .map(|&(_, _, writes)| writes) + .collect() + } + /// Every strip's cell write counts in the current epoch, per cell: `([fader left, fader /// right, mute left, mute right], matrix)`. fn current_cells(&self) -> Vec<([u64; 4], u64)> { @@ -11140,17 +11152,104 @@ fn a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback() { } } -/// #1652c: an edit that lowers a following route (a route into a submix whose source strip has a -/// mute entry) is a rebuild until #1653 gives the live commit the seven-word record: a send gain -/// edit on `eq0-b`, and a hand mute of `eq0` (which changes `eq0-b`'s follow gate). Each commits -/// with `rebuild`, the successor is adopted, and from the adoption on the rig renders what the -/// committed document renders fresh, seeked to the adoption sample. +/// #1653: sends `edits` on `rig`, which must answer `live`, and renders `blocks` blocks from the +/// next block on. Returns that block's first sample `T` (the first block that begins after the +/// submit returns, which applies the transaction's records) and the blocks, `T`'s first. +fn live_and_render(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::Live); + let first = rig.block * rig.quantum as u64; + (first, (0..blocks).map(|_| rig.step()).collect()) +} + +/// The `follows_mute` row of route `route`, set to `on`. +fn follows_mute_edit(route: &str, on: bool) -> SessionEdit { + use protocol::control_core::{Lane, Owner, Section, row_address}; + SessionEdit::set_control( + row_address( + Owner::Route(StableId::parse(route).expect("route ID")), + Section::Own, + "follows_mute", + Lane::Both, + ) + .expect("the follows_mute row"), + f32::from(u8::from(on)), + None, + ) +} + +/// #1653 gate 1 on the C ABI. `eq0` is automated-muted from 1,000 (480-sample mute ramps); +/// `eq0-b` follows it. At 1,024 a transaction turns `follows_mute` off, at 1,920 one turns it on +/// again: each is `live`. From the first block boundary after each route ramp (480 samples) the +/// output is bit for bit a fresh plan of the committed document, session-seeked to the edit's +/// block and fed the same PCM: the send open while the curve mutes, then closed again until the +/// curve's unmute at 2,500 reopens it. Not vacuous: the send's plane carries audio while it is off +/// and is silent after it is on again. +/// +/// Test value: red if `follows_mute` is still read from the prepared route only (the live toggle +/// on an automated route is lost and the send stays shut), or if the commit refuses the +/// seven-word record. +#[test] +fn a_follows_mute_toggle_on_a_following_route_is_live() { + let mut rig = Rig::new(&follow_document(true, false)); + let quantum = rig.quantum; + for _ in 0..8 { + rig.step(); + } + let settled = |at: u64| (at as usize + 480).div_ceil(quantum) - at as usize / quantum; + assert_eq!( + rig.current_route_cells(), + Vec::::new(), + "no six-word send" + ); + assert_eq!( + rig.current_follow_route_cells(), + vec![0], + "one seven-word send" + ); + for ((label, on, blocks), writes) in [("off", false, 7), ("on", true, 17)] + .into_iter() + .zip(1_u64..) + { + let (at, live) = live_and_render(&mut rig, &[follows_mute_edit("eq0-b", on)], blocks); + assert_eq!( + rig.current_follow_route_cells(), + vec![writes], + "{label}: one seven-word cell write" + ); + let reference = seeked_reference(&rig.snapshot(), at, blocks); + let from = settled(at); + for plane in 0..2 { + assert_eq!( + plane_bits(&live[from..], plane, quantum), + plane_bits(&reference[from..], plane, quantum), + "{label}, plane {plane}: a fresh plan of the committed document after the ramp" + ); + } + // The send's plane up to the curve's unmute at 2,500 (only the "on" run reaches it). + let send = plane_bits(&live[from..], 1, quantum); + let window = (2_500_usize.saturating_sub(at as usize + from * quantum)).min(send.len()); + let silent = send[..window] + .iter() + .all(|&word| f32::from_bits(word) == 0.0); + assert_eq!( + silent, on, + "{label}: the send is shut exactly when it follows" + ); + } +} + +/// #1653 (replaces #1652c's rebuild gate): a send gain edit on the following route `eq0-b`, and a +/// hand mute of its source `eq0` (which changes its terms and its gate), are each `live`, and from +/// the first block boundary after their ramps the C ABI renders what the committed document +/// renders fresh, session-seeked to the edit's block. /// -/// Test value: red if the live commit writes a six-word record to a route that has no six-word -/// cell (the commit refuses with an internal fault and the edit is lost), or if the rebuild's -/// successor does not render the committed values. +/// Test value: red if either edit still rebuilds, if the commit refuses the seven-word record +/// (an internal fault; the edit is lost), or if the record does not carry the committed values. #[test] -fn an_edit_of_a_following_route_rebuilds() { +fn an_edit_of_a_following_route_is_live() { use protocol::control_core::{Lane, Owner, Section, row_address}; let gain = SessionEdit::set_control( row_address( @@ -11168,8 +11267,8 @@ fn an_edit_of_a_following_route_rebuilds() { for _ in 0..4 { rig.step(); } - let (adoption, live) = rebuild_and_adopt(&mut rig, std::slice::from_ref(&edit), 8); - let reference = seeked_reference(&rig.snapshot(), adoption, 8); + let (at, live) = live_and_render(&mut rig, std::slice::from_ref(&edit), 12); + let reference = seeked_reference(&rig.snapshot(), at, 12); let settle = 4; assert_eq!( live[settle..] @@ -11180,7 +11279,7 @@ fn an_edit_of_a_following_route_rebuilds() { .iter() .map(|pcm| bits(pcm)) .collect::>(), - "the successor renders the committed document once its ramps settle" + "the plan renders the committed document once its ramps settle" ); } } diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index 0012e572b..18a854725 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -1180,6 +1180,7 @@ pub(crate) fn prepare_runtime( track_count, effect_controls, route_controls, + follow_route_controls, jump_lengths, master_track, .. @@ -1198,6 +1199,8 @@ pub(crate) fn prepare_runtime( track_count, // #1557 D4: the send producers, at exactly one entry per route lane the plan attached. routes: route_controls.into_boxed_slice(), + // #1653: the following routes' seven-word producers, one per attached seven-word lane. + follow_routes: follow_route_controls.into_boxed_slice(), // #1639 D1: the plan's jump-length cell writer. jump_lengths, }; diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index aabb7bf68..74fdadf92 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -19,6 +19,10 @@ pub(crate) struct StripLanes { /// One producer per route into a submix, in canonical route-ID order; empty when the plan /// carries no route lane. pub(crate) routes: Box<[host_core::RouteControlProducer]>, + /// One seven-word producer per route the plan gives the cell of a following route (#1652 D1), + /// in canonical route-ID order; empty when the plan has none. A route is in `routes` or here, + /// never in both (#1653). + pub(crate) follow_routes: Box<[host_core::GraphFollowRouteControlProducer]>, /// The writer of the plan's jump-length cell (#1639 D1), held inline; every prepared plan has /// one, and preparation refuses a handle set without it. pub(crate) jump_lengths: host_core::GraphJumpLengthProducer, @@ -390,6 +394,9 @@ pub struct TestTransactionSnapshot { /// 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)>, + /// The same for every following route's seven-word cell (#1653), in the order of + /// `route_cells`: `(epoch, route ID, writes)`. + pub follow_route_cells: Vec<(u64, Box, u64)>, /// How many writes every effect producer's bypass cell, parameter cells, then target cells, /// have taken, current epoch first, then each pending candidate: `(epoch, strip ID, effect /// instance ID, writes per cell)`. A cell's count is its write sequence (#1345 D1). @@ -1664,6 +1671,18 @@ where }) }) .collect(), + follow_route_cells: core::iter::once(&self.providers) + .chain(&self.pending_providers) + .flat_map(|provider| { + provider.strips.follow_routes.iter().map(|producer| { + ( + provider.epoch, + producer.route_id.clone(), + producer.test_only_write_count(), + ) + }) + }) + .collect(), effect_cells: core::iter::once(&self.providers) .chain(&self.pending_providers) .flat_map(|provider| { @@ -3237,9 +3256,13 @@ where } // 2a. Each route's producer, by route ID (#1557 D4): a binary search over the canonical - // route-ID order. A record for a route with no producer is an internal fault, found - // before the first write. + // route-ID order, among the six-word producers for a folded record and among the + // seven-word ones for a following route's record (#1653 D1). A record for a route with no + // producer of its kind, or a seven-word record whose `automated` bits differ from the + // plan's (render takes them from its own cursors), is an internal fault, found before the + // first write, so no ack precedes a drop (#1653 D5). let route_lanes = &mut strips.routes; + let follow_lanes = &mut strips.follow_routes; let mut resolved_routes = Vec::new(); if resolved_routes .try_reserve_exact(delta.routes.len()) @@ -3248,8 +3271,17 @@ where return LiveCommit::Done(Err(CommandError::Internal)); } for route in &delta.routes { - let Some(index) = host_core::route_producer_position(route_lanes, route.route_id) - else { + let index = match route.record { + host_core::SendRecord::Folded(_) => { + host_core::route_producer_position(route_lanes, route.route_id) + } + host_core::SendRecord::Following(record) => { + host_core::follow_route_producer_position(follow_lanes, route.route_id).filter( + |&index| follow_lanes[index].automated() == record.flags().automated, + ) + } + }; + let Some(index) = index else { return LiveCommit::Done(Err(CommandError::Internal)); }; resolved_routes.push(index); @@ -3430,8 +3462,17 @@ where } } } + // #1653: a following route's cell under the same stamp as its source strip's terms + // write, so both apply in one block. for (route, &index) in delta.routes.iter().zip(&resolved_routes) { - route_lanes[index].write(stamp, route.record); + match route.record { + host_core::SendRecord::Folded(record) => { + route_lanes[index].write(stamp, record); + } + host_core::SendRecord::Following(record) => { + follow_lanes[index].write(stamp, record); + } + } } // #1639 D3: the new jump lengths, in the same block as the records. if let Some(lengths) = delta.jump_lengths { diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 269058b98..903e2e66b 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1449,9 +1449,20 @@ pub struct GraphFollowRouteControlProducer { /// route, which bind allocates only for a plan with a following route, so the compile-time /// estimate never charges it. The same for every producer of one attach. activity_offset_bytes: u64, + /// The lanes the plan automates (the bound source's `flags.automated`, #1652 D1): render + /// takes `automated` from its own cursors, so a record whose bits differ is refused by the + /// commit before any write (#1653 D5). + automated: [bool; 2], } impl GraphFollowRouteControlProducer { + /// The source lanes whose mute the plan automates, as bound (#1653 D5): the bits every record + /// written to this cell must carry. + #[must_use] + pub const fn automated(&self) -> [bool; 2] { + self.automated + } + /// Write `record` as the route's newest value, stamped with the revision of the transaction /// that commits it. A write cannot fail and never waits; render applies the newest and counts /// each replaced one. Validation is [`FollowRouteControlRecord::new`]'s, made before any @@ -1504,6 +1515,7 @@ pub(crate) fn follow_route_control_pair( cell: engine::realtime::LatestCell::new(), dirty: engine::realtime::DirtyWord::new(), }); + let automated = source.flags().automated; ( GraphFollowRouteControlBinding { node: GraphNodeId::Route { @@ -1525,6 +1537,7 @@ pub(crate) fn follow_route_control_pair( activity_largest, activity_offset_bytes: runtime::route_activity_offset_bytes(activity_routes) .unwrap_or(u64::MAX), + automated, }, ) } diff --git a/crates/host-core/src/lib.rs b/crates/host-core/src/lib.rs index 5e7a29a25..02a8693a8 100644 --- a/crates/host-core/src/lib.rs +++ b/crates/host-core/src/lib.rs @@ -135,6 +135,9 @@ pub use engine::realtime::{ ResponseSnapshotError, ResponseSnapshotOwnerInfo, ResponseSnapshotSection, ResponseSnapshotSink, }; +/// A following route's seven-word cell (#1652 D1): what a host writes a [`SendRecord::Following`] +/// with (#1653). +pub use graph::{FollowRouteControlRecord, GraphFollowRouteControlProducer, RouteFollowFlags}; #[cfg(feature = "control-provider")] pub use live_delta::{ LiveDelta, LiveRebuild, LiveRouteRecord, LiveStripRecords, automation_jump_row, @@ -147,7 +150,7 @@ pub use prepare::{ GraphJumpLengthProducer, GraphJumpLengths, HostLiveControlHandles, HostLiveControlRequest, HostLiveLanes, HostMeterRequest, HostPrepareCaps, HostPrepareReport, HostShapePolicy, LiveRampRow, LiveRamps, MAXIMUM_VCA_REACH_PAIRS, MAXIMUM_VCAS_BOUNDED, MonitorBase, - NO_OBSERVATION, PlanStateInventory, PreparedHost, SOURCE_STALL_TOLERANCE_MS, + NO_OBSERVATION, PlanStateInventory, PreparedHost, SOURCE_STALL_TOLERANCE_MS, SendRecord, SourceInventoryRow, SuccessorBase, check_counts, compile_host_model, compile_host_session, count_effects, default_source_ring_frames, effect_producer_position, follow_route_producer_position, jump_length_retained_bytes, pair_carried_meters, diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index 4d2ac7ab4..6ad18fb5e 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -23,7 +23,6 @@ use std::cell::OnceCell; use std::collections::{BTreeMap, BTreeSet}; -use crate::route_controls::RouteControlRecord; use builtins::{checked_trim_gain, validate_prepared_input_filter_target}; use builtins_compiler::{ FaderOffsetsRecord, TrackControlRecord, TrackFaderRecord, TrackInputRecord, @@ -55,10 +54,10 @@ 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, LevelRecords, LevelRules, OutOfDomain, RouteEditRamps, StripChange, - StripEditRamps, effect_instance_records, follows_automated_mute, input_records, level_differs, - level_records_between, matrix_record, route_record_between, route_source_strip, same_params, - strip_at, + EffectiveDiff, LevelRecords, LevelRules, OutOfDomain, RouteEditRamps, SendRecord, StripChange, + StripEditRamps, effect_instance_records, follow_route_record_between, following_route_lanes, + 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}; @@ -139,12 +138,15 @@ pub struct LiveEffectRecords<'a> { #[derive(Clone, Copy, Debug, PartialEq)] pub struct LiveRouteRecord<'a> { /// The route's ID, borrowed from the post-commit model. A producer is found by it - /// ([`crate::route_producer_position`]). + /// ([`crate::route_producer_position`] for a `Folded` record, + /// [`crate::follow_route_producer_position`] for a `Following` one). pub route_id: &'a str, - /// The send's new target, its gate and its ramp length: what + /// For a six-word route, `Folded`: the send's new target, its gate and its ramp length, what /// `graph_compiler::route_coefficients` gives for the post-commit values, which is what - /// preparation folds into the route's constants. - pub record: RouteControlRecord, + /// preparation folds into the route's constants. For a route the plan gives the seven-word + /// cell of a following route (#1652 D1), `Following`: the open transform, the flags word and + /// the ramp (#1653 D1), which render composes with the source strip's stored mute curve. + pub record: SendRecord, } /// A live delta: the records that bring the running plan to the post-commit model. @@ -1021,7 +1023,19 @@ fn lower_routes<'a>( continue; } let gates = diff.gates(index).ok_or(LiveRebuild::Structure)?; - if !(routes.contains(&after.id) || gates[0] != gates[1]) { + // #1653: a route the plan gives the seven-word cell also holds its source lanes' effective + // mute (`terms`), which can change while the gate does not (`follows_mute` off), so the + // cell always holds the committed words. + let following = match following_route_lanes(next.structure(), after) { + Some(automated) => Some(Following { + terms: diff.terms(before, after).ok_or(LiveRebuild::Structure)?, + automated, + }), + None => None, + }; + let terms_changed = + following.is_some_and(|following| following.terms[0] != following.terms[1]); + if !(routes.contains(&after.id) || gates[0] != gates[1] || terms_changed) { continue; } let edit = RouteEditRamps::of(edit_ramps, &after.id); @@ -1037,6 +1051,7 @@ fn lower_routes<'a>( gates, gate_length, edit, + following, }); if let Lowered::Route(Some(record)) = lower(Family::Route, &ctx, &subject)? { delta.routes.push(record); @@ -1065,6 +1080,9 @@ trait LevelDiff { own_edit: Option, gates: [[bool; 2]; 2], ) -> Option; + /// The effective mute of the route's source strip per lane, in `[current, next]`: a following + /// route's `terms` (#1652 D1, OQ1 = C), or `None` when the form does not hold the source. + fn terms(&self, before: &Route, after: &Route) -> Option<[[bool; 2]; 2]>; } impl LevelDiff for EffectiveDiff<'_> { @@ -1092,6 +1110,11 @@ impl LevelDiff for EffectiveDiff<'_> { self, before, after, own_edit, source, gates, )) } + + fn terms(&self, before: &Route, after: &Route) -> Option<[[bool; 2]; 2]> { + let source = self.source_strips(before, after)?; + Some(self.strip_mutes(source.0, source.1)) + } } /// What an edit reaches, for [`LocalDiff`]. @@ -1173,13 +1196,14 @@ impl<'a> LocalDiff<'a> { } // The sends that follow a strip whose mute moved: one scan comparing sources, and only when - // a mute moved. + // a mute moved. A route the plan gives the seven-word cell is held whatever its + // `follows_mute`: its cell holds the source lanes' mute (`terms`, #1653). if strips .iter() .any(|strip| strip.before.mute != strip.after.mute) { for (index, route) in next.routes.iter().enumerate() { - if !route.follows_mute { + if !route.follows_mute && following_route_lanes(next, route).is_none() { continue; } let Some(source) = route_source_strip(next, route) else { @@ -1312,6 +1336,11 @@ impl LevelDiff for LocalDiff<'_> { .gate_length(before, after, own_edit, &self.change(source), gates), ) } + + fn terms(&self, _before: &Route, after: &Route) -> Option<[[bool; 2]; 2]> { + let source = self.strip(route_source_strip(self.rules.next.structure(), after)?)?; + Some([source.before.mute, source.after.mute]) + } } /// The changed controls of one strip, from the control list's order. @@ -1369,6 +1398,18 @@ struct RouteTarget<'m, 'a> { /// The ramp length of a record whose gate changed (#1558 D7); unused when it did not. gate_length: u32, edit: RouteEditRamps, + /// For a route the next plan gives the seven-word cell (#1652 D1), what its record holds + /// besides the route's own values; `None` for a six-word route. + following: Option, +} + +/// What a following route's record reads of its source strip (#1653 D1, D2). +#[derive(Clone, Copy)] +struct Following { + /// The source lanes' effective mute, in `[before, after]` (OQ1 = C: it holds no curve). + terms: [[bool; 2]; 2], + /// The lanes the next plan automates. + automated: [bool; 2], } /// What a strip lowering is of: a strip in both models, or one effect instance of it. @@ -1461,37 +1502,45 @@ fn lower<'a>( } } -/// The record that brings a send from `before`'s live values to `after`'s (#1557 D2, D3), when the -/// target the render plane holds changes: [`route_record_between`] of the lowering's inputs, the -/// one function of a send (#1284 D3), which a successor's preparation asks too. +/// The record that brings a send from `before`'s live values to `after`'s (#1557 D2, D3), when a +/// word the render plane holds changes: the one function of a send (#1284 D3), which a successor's +/// preparation asks too. For a six-word route it is [`route_record_between`]; for a route the plan +/// gives the seven-word cell of a following route (#1652 D1), [`follow_route_record_between`] +/// with its source lanes' `terms` and the plan's `automated` (#1653 D1), so no folded record, which +/// the curve's next event would contradict, reaches it. Automation is structure for a value +/// transaction, so the predicate read the view's structure. /// /// # Errors /// /// [`LiveRebuild::Domain`] when either value set is outside the route domain, or a ramp is longer -/// than the cell's maximum. [`LiveRebuild::RebuildOnly`] for a route the plan gives the seven-word -/// cell of a following route (#1652 D1): its live record is #1653's, and until it lands a rebuild -/// prepares the successor's cell from the committed model and the send join writes its -/// seven-word record (#1652 D3), so no commit writes a six-word record to a route that has no -/// six-word cell. Automation is structure for a value transaction, so the predicate reads the -/// view's structure. +/// than the cell's maximum. fn route_records<'a>( ctx: &Context<'_>, target: &RouteTarget<'_, 'a>, ) -> Result>, LiveRebuild> { - if follows_automated_mute(ctx.next.structure(), target.after) { - return Err(LiveRebuild::RebuildOnly( - controls::RebuildReason::RuledLiveNotBuilt { issue: 1653 }, - )); - } - Ok(route_record_between( - ctx.ramps, - target.before, - target.after, - target.gates, - target.gate_length, - target.edit, - )? - .map(|record| LiveRouteRecord { + let record = match target.following { + Some(following) => follow_route_record_between( + ctx.ramps, + target.before, + target.after, + following.terms, + following.automated, + target.gates, + target.gate_length, + target.edit, + )? + .map(SendRecord::Following), + None => route_record_between( + ctx.ramps, + target.before, + target.after, + target.gates, + target.gate_length, + target.edit, + )? + .map(SendRecord::Folded), + }; + Ok(record.map(|record| LiveRouteRecord { route_id: target.after.id.as_str(), record, })) diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 68fd4cc8d..46b13f5aa 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -4325,15 +4325,38 @@ pub(crate) fn route_record_between( } /// The record a send's cell takes (#1652 D1): the six folded words of #1347, or, for a route a -/// plan gives the seven-word cell ([`follows_automated_mute`]), its open transform and flags. +/// plan gives the seven-word cell (a route into a submix whose source strip automates its mute), +/// its open transform and flags. A host writes a `Folded` record through the route's +/// [`RouteControlProducer`](crate::RouteControlProducer) and a `Following` one through its +/// [`GraphFollowRouteControlProducer`](graph::GraphFollowRouteControlProducer) (#1653). #[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) enum SendRecord { - /// A six-word route's record ([`route_record_between`]). +pub enum SendRecord { + /// A six-word route's record (`route_record_between`). Folded(RouteControlRecord), - /// A following route's record ([`follow_route_record_between`]). + /// A following route's record (`follow_route_record_between`). Following(graph::FollowRouteControlRecord), } +impl SendRecord { + /// The six-word record, or `None` for a following route's. + #[must_use] + pub const fn folded(&self) -> Option<&RouteControlRecord> { + match self { + Self::Folded(record) => Some(record), + Self::Following(_) => None, + } + } + + /// The seven-word record, or `None` for a six-word route's. + #[must_use] + pub const fn following(&self) -> Option<&graph::FollowRouteControlRecord> { + match self { + Self::Following(record) => Some(record), + Self::Folded(_) => None, + } + } +} + /// The seven-word record of a following route (#1652 D1) as `route` holds it: the open transform /// (`graph_compiler::route_values`, the derivation the compiler lowers the plan's transform /// with, so a record's words and the plan's are one bit pattern), the flags word (the route's @@ -5046,13 +5069,27 @@ fn route_source_id(route: &session::Route) -> &session::StableId { /// other route keeps the six-word cell. A route into the output never follows. #[must_use] pub(crate) fn follows_automated_mute(model: &SessionModel, route: &session::Route) -> bool { - matches!( + following_route_lanes(model, route).is_some() +} + +/// The source lanes whose mute a plan of `model` automates for `route`, when that plan gives +/// `route` the seven-word cell of a following route ([`follows_automated_mute`]), else `None`: +/// the `automated` bits of the route's records (#1652 D1, #1653 D1). +#[must_use] +pub(crate) fn following_route_lanes( + model: &SessionModel, + route: &session::Route, +) -> Option<[bool; 2]> { + if !matches!( route.destination, session::RouteDestination::SubmixInput { .. } - ) && !model.automation.is_empty() - && controls::mute_automation(model, route_source_id(route)) - .iter() - .any(Option::is_some) + ) || model.automation.is_empty() + { + return None; + } + let automated = + controls::mute_automation(model, route_source_id(route)).map(|entry| entry.is_some()); + (automated != [false; 2]).then_some(automated) } /// The jump length of a stored mute curve on a following route (#1652 D2, #1652b): the mute row's diff --git a/crates/host-core/tests/in_place_commit.rs b/crates/host-core/tests/in_place_commit.rs index 565efd118..e77c63a72 100644 --- a/crates/host-core/tests/in_place_commit.rs +++ b/crates/host-core/tests/in_place_commit.rs @@ -315,6 +315,32 @@ fn the_parity_script_commits_as_the_full_path_does() { } } +/// #1653 gate 3 through the in-place path (R17-1): the edits of a following route (a mute entry on +/// `t1`, its following send `t1-b`) through both chains, in order, at two rates; the stores equal +/// after each, and every value edit takes the in-place form with the full path's delta. +/// +/// Red if the fast classifier lowers a following route's terms or `follows_mute` change +/// differently from the dense one (a missing terms trigger, a folded record), or if those rows do +/// not take the in-place form. +#[test] +fn a_following_routes_edits_commit_as_the_full_path_does() { + for rate in [48_000, 96_000] { + let mut pair = Pair::new(following_session(rate)); + let mut tally = Tally::default(); + let script = following_script(); + for edits in &script { + pair.step(edits, &["following"], true, &mut tally); + } + assert_eq!(tally.refused, 0, "nothing is refused"); + assert_eq!(tally.monitor_only, 2, "the solo steps"); + assert_eq!( + tally.in_place, + script.len() - 2, + "every value edit took the in-place form" + ); + } +} + /// Gate 1b. A seeded random walk of at least 2,400 transactions of one to four edits drawn from /// every row of the control list (every class, the session's rows, a strip's matrix mode, an insert's /// link mode, the solo rows), with and without a per-edit ramp, over a random committed overlay, diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 615c42a21..771a1f8cd 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -482,9 +482,9 @@ fn classify_both( .map(|route| { ( route.route_id.to_owned(), - route.record.target(), - route.record.mute(), - route.record.length(), + route.record.folded().expect("a six-word route").target(), + route.record.folded().expect("a six-word route").mute(), + route.record.folded().expect("a six-word route").length(), ) }) .collect(); @@ -2467,9 +2467,22 @@ fn push_delta( } } for route in &delta.routes { - let index = host_core::route_producer_position(&handles.route_controls, route.route_id) - .expect("route lane"); - handles.route_controls[index].write(stamp, route.record); + match route.record { + host_core::SendRecord::Folded(record) => { + let index = + host_core::route_producer_position(&handles.route_controls, route.route_id) + .expect("route lane"); + handles.route_controls[index].write(stamp, record); + } + host_core::SendRecord::Following(record) => { + let index = host_core::follow_route_producer_position( + &handles.follow_route_controls, + route.route_id, + ) + .expect("following route lane"); + handles.follow_route_controls[index].write(stamp, record); + } + } } }); } @@ -4099,9 +4112,9 @@ fn classify_routes_with( .map(|route| { ( route.route_id.to_owned(), - route.record.target(), - route.record.mute(), - route.record.length(), + route.record.folded().expect("a six-word route").target(), + route.record.folded().expect("a six-word route").mute(), + route.record.folded().expect("a six-word route").length(), ) }) .collect() @@ -4637,9 +4650,9 @@ fn classify_monitor( .map(|route| { ( route.route_id.to_owned(), - route.record.target(), - route.record.mute(), - route.record.length(), + route.record.folded().expect("a six-word route").target(), + route.record.folded().expect("a six-word route").mute(), + route.record.folded().expect("a six-word route").length(), ) }) .collect(); @@ -5155,3 +5168,216 @@ fn a_mute_curve_takes_its_terms_as_mute_records() { ); } } + +// ---- #1653 gate 3: a following route's edits --------------------------------------------------- + +/// [`with_bus`]`(follows_mute)` with a mute entry on the first track's **left** lane (steps 0, 1, +/// 0 at 1,000 and 2,500): its send into `bus` is a following route (#1652 D1), automated on the +/// left lane only. +fn with_automated_follow(follows_mute: bool) -> SessionModel { + let mut model = with_bus(follows_mute); + let step = |start: u64, end: u64, from: f32, to: f32| AutomationSegment { + shape: AutomationShape::Step, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: ParameterUnit::Linear, + }; + model.automation.push(Automation { + id: id("left-mute"), + target: ControlAddress { + owner: Owner::Strip(model.tracks[0].id.clone()), + section: Section::Builtins, + control: 6, + lane: ParameterChannel::Left, + }, + segments: vec![step(0, 1_000, 0.0, 1.0), step(1_000, 2_500, 1.0, 0.0)], + offset: session::AutomationOffset::default(), + }); + normalized(&model) +} + +/// The delta of `current -> next` (the dense classifier), after checking that the fast path over +/// the rows `rows` gives the same delta (#1613, R17-1). +fn following_delta<'a>( + current: &SessionModel, + next: &'a SessionModel, + rows: &[ControlAddress], +) -> LiveDelta<'a> { + let ramps = LiveRamps::for_session(next); + let dense = host_core::classify_live_delta_with_monitor( + current, + next, + ramps, + &EditRamps::new(), + host_core::MonitorBase::NONE, + ) + .expect("a live delta"); + if !rows.is_empty() { + let mut staged = host_core::classify_live_staged( + current, + controls::ModelView::whole(next), + ramps, + &EditRamps::new(), + host_core::MonitorBase::NONE, + rows, + ) + .expect("the fast path answers"); + staged.derived_owners = None; + assert_eq!(staged, dense, "the fast path's delta"); + } + dense +} + +/// The seven-word records of `delta`, after checking it holds no folded one. +fn seven_word(delta: &LiveDelta<'_>) -> Vec<(String, host_core::FollowRouteControlRecord)> { + delta + .routes + .iter() + .map(|route| { + let record = route + .record + .following() + .unwrap_or_else(|| panic!("{}: a folded record", route.route_id)); + (route.route_id.to_owned(), *record) + }) + .collect() +} + +/// The `[left, right]` lanes and values of the `Mute` records of the delta's only strip. +fn mute_records(delta: &LiveDelta<'_>) -> Vec<(BuiltinLaneSelector, bool)> { + delta + .strips + .iter() + .flat_map(|strip| strip.fader_records()) + .filter_map(|record| match record { + TrackFaderRecord::Mute { lanes, muted, .. } => Some((lanes, muted)), + TrackFaderRecord::FaderDb { .. } => None, + }) + .collect() +} + +/// #1653 gate 3. On a following route (`track 0 -> bus`, left lane automated): a `follows_mute` +/// toggle gives one seven-word record and no folded record; a hand mute on the automated lane +/// gives #1651's terms write (its `Mute` record) and one seven-word record with the new `terms`; a +/// hand mute on the other lane gives that lane's `Mute` record and one seven-word record; a route +/// gain edit gives one seven-word record with the new open transform; a terms change while +/// `follows_mute` is off gives one seven-word record whose gate is open whatever the curve (no +/// audible change); an edit that leaves every word unchanged (a fader move on the source, an +/// identical rewrite) gives no route record. Each case gives the same delta through the fast path. +/// +/// Test value: red if an edit still emits a folded record to a route whose cell has seven words +/// (the curve's next event would contradict it), if a terms change with `follows_mute` off is not +/// lowered (the cell keeps stale words), or if an unchanged cell is written. +#[test] +fn a_following_routes_edits_write_its_seven_word_cell_only() { + let current = with_automated_follow(true); + let track = current.tracks[0].id.clone(); + let send = current + .routes + .iter() + .position(|route| route.follows_mute) + .expect("the following send"); + let send_id = current.routes[send].id.clone(); + let route_row = |control: u32| ControlAddress { + owner: Owner::Route(send_id.clone()), + section: Section::Own, + control, + lane: ParameterChannel::Both, + }; + let strip_row = |control: u32, lane: ParameterChannel| ControlAddress { + owner: Owner::Strip(track.clone()), + section: Section::Builtins, + control, + lane, + }; + let automated = [true, false]; + + // A `follows_mute` toggle. + let next = edited(¤t, |next| next.routes[send].follows_mute = false); + let delta = following_delta(¤t, &next, &[route_row(7)]); + assert!(delta.strips.is_empty()); + let records = seven_word(&delta); + assert_eq!(records.len(), 1, "one seven-word record"); + let flags = records[0].1.flags(); + assert_eq!( + (flags.follows_mute, flags.terms, flags.automated), + (false, [false; 2], automated) + ); + + // A hand mute on the automated lane, then on the other lane. + for (lane, selector, terms) in [ + ( + ParameterChannel::Left, + BuiltinLaneSelector::Left, + [true, false], + ), + ( + ParameterChannel::Right, + BuiltinLaneSelector::Right, + [false, true], + ), + ] { + let next = edited(¤t, |next| match lane { + ParameterChannel::Left => next.tracks[0].fader.left_mute = true, + _ => next.tracks[0].fader.right_mute = true, + }); + let delta = following_delta(¤t, &next, &[strip_row(6, lane)]); + assert_eq!(mute_records(&delta), vec![(selector, true)], "{lane:?}"); + let records = seven_word(&delta); + assert_eq!(records.len(), 1, "{lane:?}: one seven-word record"); + assert_eq!(records[0].1.flags().terms, terms, "{lane:?}"); + assert!(records[0].1.flags().follows_mute, "{lane:?}"); + } + + // A route gain edit: the new open transform. + let next = edited(¤t, |next| next.routes[send].gain_db = -6.0); + let delta = following_delta(¤t, &next, &[route_row(1)]); + assert!(delta.strips.is_empty()); + let records = seven_word(&delta); + assert_eq!(records.len(), 1, "one seven-word record"); + let matrix = &next.routes[send].channel_matrix; + let expected = graph_compiler::route_values(-6.0, [matrix.ll, matrix.lr, matrix.rl, matrix.rr]) + .expect("in domain"); + let words = |transform: graph::RouteTransform| { + [ + transform.gain, + transform.ll, + transform.lr, + transform.rl, + transform.rr, + ] + .map(f32::to_bits) + }; + assert_eq!(words(records[0].1.transform()), words(expected)); + + // A terms change while `follows_mute` is off: a record, and the gate stays open. + let unfollowing = edited(¤t, |next| next.routes[send].follows_mute = false); + let next = edited(&unfollowing, |next| next.tracks[0].fader.right_mute = true); + let delta = following_delta( + &unfollowing, + &next, + &[strip_row(6, ParameterChannel::Right)], + ); + assert_eq!( + mute_records(&delta), + vec![(BuiltinLaneSelector::Right, true)] + ); + let records = seven_word(&delta); + assert_eq!(records.len(), 1, "one seven-word record"); + let flags = records[0].1.flags(); + assert_eq!((flags.follows_mute, flags.terms), (false, [false, true])); + for curve in [[false; 2], [true, false], [false, true], [true; 2]] { + assert_eq!(flags.gate(curve), graph::RouteGate::OPEN, "{curve:?}"); + } + + // Edits that leave every word of the cell unchanged. + let next = edited(¤t, |next| next.tracks[0].fader.left_db = -3.0); + let delta = following_delta(¤t, &next, &[strip_row(5, ParameterChannel::Left)]); + assert_eq!(delta.strips.len(), 1, "the fader record"); + assert!(delta.routes.is_empty(), "a fader move writes no route cell"); + let next = edited(¤t, |_| {}); + let delta = following_delta(¤t, &next, &[]); + assert!(delta.is_empty(), "an identical rewrite writes nothing"); +} diff --git a/crates/host-core/tests/live_routes.rs b/crates/host-core/tests/live_routes.rs index 3a0e23120..449a0b01a 100644 --- a/crates/host-core/tests/live_routes.rs +++ b/crates/host-core/tests/live_routes.rs @@ -1129,3 +1129,200 @@ fn live_sends_render_without_allocating() { }, ); } + +// ---- #1653 gate 1 (host-core form) ---------------------------------------------------------------- + +/// [`follow_session`] with `t-b` following `t`'s mute, a 480-sample session mute length and a +/// mute entry on `t`'s `both` lanes with steps 0, 1, 0 at samples 1,000 and 2,500: `t-b` is a +/// following route (#1652 D1), and `follows_mute` is `follows`. +#[cfg(all(feature = "test-support", feature = "control-provider"))] +fn automated_follow_session(follows: bool) -> SessionModel { + use session::{ + Automation, AutomationSegment, AutomationShape, ControlAddress, ControlSmoothing, Owner, + ParameterChannel, ParameterUnit, Section, + }; + let mut model = follow_session(Values::UNITY, follows, [false; 2], Values::UNITY); + model.control_smoothing = Some(ControlSmoothing { + mute_ms: 10.0, + fader_ms: 5.0, + pan_ms: 5.0, + }); + let step = |start: u64, end: u64, from: f32, to: f32| AutomationSegment { + shape: AutomationShape::Step, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: ParameterUnit::Linear, + }; + model.automation.push(Automation { + id: sid("t-mute"), + target: ControlAddress { + owner: Owner::Strip(sid("t")), + section: Section::Builtins, + control: 6, + lane: ParameterChannel::Both, + }, + segments: vec![step(0, 1_000, 0.0, 1.0), step(1_000, 2_500, 1.0, 0.0)], + offset: session::AutomationOffset::default(), + }); + model +} + +#[cfg(all(feature = "test-support", feature = "control-provider"))] +impl Live { + /// `model` prepared with live controls on `backend`. + fn on(model: &SessionModel, feeds: &Feeds, backend: Backend) -> Self { + let compiled = compile_host_session(&document(model), &caps()) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + let (mut host, handles) = host_core::test_only_prepare_host_runtime_with_live_controls_on( + &compiled, + &caps(), + &HostLiveControlRequest::default(), + backend, + ) + .unwrap_or_else(|failure| { + panic!("prepare: {}", String::from_utf8_lossy(failure.as_bytes())) + }); + let gate = host + .plan + .take_revision_gate() + .expect("the plan's gate handle"); + Self { + host, + handles, + snapshot: gate.last_published(), + gate: Some(gate), + feeds: feeds.clone(), + block: 0, + pcm: vec![0.0; QUANTUM * 2], + } + } + + /// Classifies `current -> next` (both normalized) and writes the delta's route records through + /// the producers of their kind, as one transaction. Returns the delta's record counts: + /// `(strips, folded routes, following routes)`. + fn commit(&mut self, current: &SessionModel, next: &SessionModel) -> (usize, usize, usize) { + let delta = host_core::classify_live_delta( + current, + next, + host_core::LiveRamps::for_session(next), + &session::EditRamps::new(), + ) + .expect("a live delta"); + let following = delta + .routes + .iter() + .filter(|route| route.record.following().is_some()) + .count(); + let counts = ( + delta.strips.len(), + delta.routes.len() - following, + following, + ); + let handles = &mut self.handles; + self.gate + .as_mut() + .expect("the gate stays with the host") + .write_published(|stamp| { + for route in &delta.routes { + let host_core::SendRecord::Following(record) = route.record else { + panic!("a following route takes the seven-word record"); + }; + let index = host_core::follow_route_producer_position( + &handles.follow_route_controls, + route.route_id, + ) + .expect("the route's seven-word producer"); + handles.follow_route_controls[index].write(stamp, record); + } + }); + counts + } +} + +/// #1653 gate 1, host-core form, at every bank width with the `Scalar` oracle (R17-4). Track `t` +/// is automated-muted from 1,000 to 2,500 and `t-b` follows it into bus `b`. At 1,024 the +/// classifier's delta for `follows_mute` off (one seven-word record, nothing else) is written +/// through the route's seven-word producer; at 1,920 the one for `follows_mute` on. From the first +/// block boundary after each 480-sample route ramp, the output is bit for bit a fresh plan of the +/// committed model (fed the same sources from 0, on the same backend): the send open while the +/// curve mutes, then shut until the curve's unmute reopens it. Every width renders the scalar +/// oracle's bits. +/// +/// Test value: red if a `follows_mute` toggle on a following route gives a folded record or none +/// (the toggle is lost, and the send stays shut), or if the seven-word record carries the wrong +/// flags. +#[cfg(all(feature = "test-support", feature = "control-provider"))] +#[test] +fn a_follows_mute_toggle_on_a_following_route_settles_on_a_fresh_plan() { + const BLOCKS: usize = 32; + let backends = [ + #[cfg(target_feature = "avx2")] + Backend::Simd8, + Backend::Simd4, + Backend::Scalar, + ]; + let normalized = |model: &SessionModel| { + compile_host_session(&document(model), &caps()) + .expect("compiles") + .normalized_model() + .clone() + }; + let following = normalized(&automated_follow_session(true)); + let send = route_index(&following, "t-b"); + let mut open = following.clone(); + open.revision += 1; + open.routes[send].follows_mute = false; + let mut shut = open.clone(); + shut.revision += 1; + shut.routes[send].follows_mute = true; + let mut draw = Draw::new(1_653_001); + let feeds = feeds(&mut draw, &["t", "u"], BLOCKS * QUANTUM); + let mut oracle: Option = None; + for backend in backends { + let what = format!("{backend:?}"); + let mut live = Live::on(&following, &feeds, backend); + let mut actual = live.render(8); + assert_eq!(live.commit(&following, &open), (0, 0, 1), "{what}: off"); + live.render_into(7, &mut actual); + assert_eq!(live.commit(&open, &shut), (0, 0, 1), "{what}: on"); + live.render_into(BLOCKS - 15, &mut actual); + let fresh_open = Live::on(&open, &feeds, backend).render(BLOCKS); + let fresh_shut = Live::on(&shut, &feeds, backend).render(BLOCKS); + let off_window = (1_024 + 480_usize).div_ceil(QUANTUM) * QUANTUM..15 * QUANTUM; + let on_window = (1_920 + 480_usize).div_ceil(QUANTUM) * QUANTUM..BLOCKS * QUANTUM; + for plane in 0..2 { + assert_eq!( + actual[plane][off_window.clone()] + .iter() + .map(|sample| sample.to_bits()) + .collect::>(), + fresh_open[plane][off_window.clone()] + .iter() + .map(|sample| sample.to_bits()) + .collect::>(), + "{what}: plane {plane} with follows_mute off" + ); + } + assert_planes( + &actual, + &fresh_shut, + on_window.start, + &format!("{what}: on"), + ); + // Not vacuous: the shut plan differs from the open one while the curve mutes. + assert!( + first_difference( + &fresh_open[0][off_window.clone()], + &fresh_shut[0][off_window.clone()] + ) + .is_some(), + "{what}: the toggle is audible" + ); + match &oracle { + None => oracle = Some(actual), + Some(oracle) => assert_planes(&actual, oracle, 0, &format!("{what}: the oracle")), + } + } +} diff --git a/crates/host-core/tests/support/edit_sessions.rs b/crates/host-core/tests/support/edit_sessions.rs index 9fc912498..a74912f9b 100644 --- a/crates/host-core/tests/support/edit_sessions.rs +++ b/crates/host-core/tests/support/edit_sessions.rs @@ -333,6 +333,62 @@ pub(crate) fn parity_session(rate: u32) -> SessionModel { model } +/// #1653: [`parity_session`] with a mute entry on `t1`'s left lane (steps 0, 1, 0 at 1,000 and +/// 2,500), so `t1-b` (pre-fader into `b`, following `t1`'s mute) is a following route (#1652 D1), +/// automated on the left lane only. +pub(crate) fn following_session(rate: u32) -> SessionModel { + use session::{ + Automation, AutomationOffset, AutomationSegment, AutomationShape, ParameterUnit, + }; + let mut model = parity_session(rate); + let step = |start: u64, end: u64, from: f32, to: f32| AutomationSegment { + shape: AutomationShape::Step, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: ParameterUnit::Linear, + }; + model.automation.push(Automation { + id: id("t1-mute"), + target: strip_row("t1", MUTE, Lane::Left), + segments: vec![step(0, 1_000, 0.0, 1.0), step(1_000, 2_500, 1.0, 0.0)], + offset: AutomationOffset::default(), + }); + model +} + +/// #1653 gate 3's edits on [`following_session`], in order: `follows_mute` off, a terms change +/// while it is off, `follows_mute` on, a hand mute on the automated lane and its release, a hand +/// mute on the other lane and its release, a send gain edit, an identical rewrite of it, a fader +/// move on the source, and a solo of another track and its release. +pub(crate) fn following_script() -> Vec> { + let route = |control: u32, value: f32| { + vec![set( + address(Owner::Route(id("t1-b")), Section::Own, control, Lane::Both), + value, + )] + }; + let mute = + |lane: Lane, on: bool| vec![set(strip_row("t1", MUTE, lane), f32::from(u8::from(on)))]; + let solo = |on: bool| vec![set(own_row("t2", SOLO), f32::from(u8::from(on)))]; + vec![ + route(7, 0.0), + mute(Lane::Right, true), + mute(Lane::Right, false), + route(7, 1.0), + mute(Lane::Left, true), + mute(Lane::Left, false), + mute(Lane::Right, true), + mute(Lane::Right, false), + route(1, -6.0), + route(1, -6.0), + vec![set(strip_row("t1", FADER_DB, Lane::Both), -3.0)], + solo(true), + solo(false), + ] +} + /// One scripted edit of `control_parity.rs`'s `script()`, as the `SET_CONTROL` edits both hosts /// send. pub(crate) struct Scripted { diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 0c012e713..675a326e1 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -5412,3 +5412,231 @@ fn a_rebuild_that_drops_a_following_route_to_six_words_reports_the_fallback_alik ); } } + +/// #1653: one `SET_CONTROL` of `value` on `owner`'s own row `control` (a route or a VCA row, or a +/// strip's solo). +fn own_edit(owner: Owner, control: u32, value: f32) -> SessionEdit { + SessionEdit::set_control( + address(owner, Section::Own, control, Lane::Both), + value, + None, + ) +} + +/// #1653: `edits` on both hosts as one transaction each: one verdict, one reply, path `path`. +fn both_commit( + browser: &mut Rig, + c_abi: &mut CAbi, + edits: &[SessionEdit], + path: EditPath, + label: &str, +) { + 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!( + matches!(b, Verdict::Applied { path: applied, .. } if applied == path), + "{label}: {b:?}, not {path:?}" + ); +} + +/// #1653: `model` on both hosts for `blocks` blocks, with `edits[i] = (block, edits, path)` sent +/// before block `block` renders. Both hosts render the same bits each block; returns them. +fn both_run( + model: &SessionModel, + blocks: u64, + edits: &[(u64, Vec, EditPath)], +) -> Vec> { + let mut browser = Rig::new(model); + let mut c_abi = CAbi::new(model); + let mut out = Vec::new(); + for block in 0..blocks { + for (at, edit, path) in edits { + if *at == block { + both_commit( + &mut browser, + &mut c_abi, + edit, + *path, + &format!("block {block}"), + ); + } + } + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "block {block}: the two hosts agree"); + out.push(b); + } + out +} + +/// #1653 gate 1 on both hosts. `t1` is automated-muted from 1,000 (480-sample mute ramps) and +/// `t1-b` follows it. Before block 8 (1,024) a `SET_CONTROL` turns `t1-b`'s `follows_mute` off, +/// before block 15 (1,920) one turns it on: on both hosts each is `live`, with one reply. From the +/// first block boundary after each 480-sample route ramp, each host's output is bit for bit a +/// fresh plan of the committed model (fed the same PCM from 0): the send open while the curve +/// mutes, then shut until the curve's unmute. Not vacuous: the two committed models' plans differ +/// in the first window. +/// +/// Test value: red if the browser or the C ABI loses a live `follows_mute` toggle on an automated +/// route (the commit reads `follows_mute` from the prepared route only, or refuses the seven-word +/// record), or if the hosts disagree. +#[test] +fn a_follows_mute_toggle_on_a_following_route_is_live_on_both_hosts() { + const BLOCKS: u64 = 32; + let quantum = u64::from(QUANTUM); + let toggle = |on: bool| { + vec![own_edit( + Owner::Route(id("t1-b")), + ROUTE_FOLLOWS_MUTE_ROW, + f32::from(u8::from(on)), + )] + }; + let following = follow_model(true, false); + let live = both_run( + &following, + BLOCKS, + &[ + (8, toggle(false), EditPath::Live), + (15, toggle(true), EditPath::Live), + ], + ); + let mut open = following.clone(); + for route in &mut open.routes { + if route.id.as_str() == "t1-b" { + route.follows_mute = false; + } + } + let fresh_open = both_run(&open, 15, &[]); + let fresh_shut = both_run(&following, BLOCKS, &[]); + let first_after = |sample: u64| sample.div_ceil(quantum); + let off = first_after(8 * quantum + 480)..15; + for block in off.clone() { + assert_eq!( + live[block as usize], fresh_open[block as usize], + "block {block}: the plan with follows_mute off" + ); + } + for block in first_after(15 * quantum + 480)..BLOCKS { + assert_eq!( + live[block as usize], fresh_shut[block as usize], + "block {block}: the plan with follows_mute on" + ); + } + assert!( + off.clone() + .any(|block| fresh_open[block as usize] != fresh_shut[block as usize]), + "the toggle is audible" + ); +} + +/// #1653 gate 2 on both hosts: solo and a VCA mute compose with `t1`'s mute curve for `t1-b` as +/// for the strip. `t1` has its mute entry (curve 1 on `[1,000, 2,500)`) and, for the VCA case, is +/// VCA `v`'s only member and sends `t1-pre` from its `pre_fader` tap straight to the output. +/// +/// - Solo `t2` before block 2 (the curve is 0): from the first block boundary after the 480-sample +/// ramps to block 14, both hosts render what the session with no mute entry renders under the +/// same solo: `t1` muted by solo, its send shut with it. Unsolo before block 14 (the curve is 1): +/// from block 20 on, the curve's own plan with no solo (the send stays shut by the curve, and +/// reopens with it at 2,500). +/// - A VCA mute of `v` before block 2 and its release before block 14: the same comparisons. +/// - A route into the output never follows: under the VCA mute `t1-pre` still plays (the session +/// without it renders other bits), while the twin comparisons hold. +/// +/// Test value: red if solo or a VCA mute overwrites the curve instead of composing with it for the +/// send (the send reopens on the release while the curve still mutes, or does not follow the solo +/// while the curve is 0), or if the hosts disagree. +#[test] +fn solo_and_a_vca_mute_compose_with_the_curve_for_a_following_send_on_both_hosts() { + const BLOCKS: u64 = 32; + let solo = |on: bool| { + vec![own_edit( + Owner::Strip(id(T2.id)), + SOLO, + f32::from(u8::from(on)), + )] + }; + let vca_mute = |on: bool| vec![own_edit(Owner::Vca(id("v")), 2, f32::from(u8::from(on)))]; + // `t1` alone in `v`, with a pre-fader route straight to the output (`pre_out`). + let with_vca = |mut model: SessionModel, pre_out: bool| { + for vca in &mut model.vcas { + if vca.id.as_str() == "v" { + vca.members = vec![id(T1.id)]; + } + } + if pre_out { + let mut route = model + .routes + .iter() + .find(|route| route.id.as_str() == "t1-b") + .expect("the send") + .clone(); + route.id = id("t1-pre"); + route.follows_mute = false; + route.destination = model + .routes + .iter() + .find(|route| { + matches!( + route.destination, + session::RouteDestination::OutputInput { .. } + ) + }) + .expect("a route to the output") + .destination + .clone(); + model.routes.push(route); + model.routes.sort_by(|left, right| left.id.cmp(&right.id)); + } + model + }; + for (label, edit, path, curve, twin, bare) in [ + ( + "solo", + &solo as &dyn Fn(bool) -> Vec, + EditPath::Monitor, + follow_model(true, false), + follow_model(false, false), + None, + ), + ( + "vca", + &vca_mute as &dyn Fn(bool) -> Vec, + EditPath::Live, + with_vca(follow_model(true, false), true), + with_vca(follow_model(false, false), true), + Some(with_vca(follow_model(true, false), false)), + ), + ] { + let composed = both_run( + &curve, + BLOCKS, + &[(2, edit(true), path), (14, edit(false), path)], + ); + let static_twin = both_run(&twin, 14, &[(2, edit(true), path)]); + let curve_only = both_run(&curve, BLOCKS, &[]); + for block in 6..14 { + assert_eq!( + composed[block], static_twin[block], + "{label}, block {block}: muted by the term while the curve is 0, then by both" + ); + } + assert_ne!( + composed[6], curve_only[6], + "{label}: the term mutes t1 and its send while the curve is 0" + ); + for block in 20..BLOCKS as usize { + assert_eq!( + composed[block], curve_only[block], + "{label}, block {block}: the release restores the curve's state" + ); + } + if let Some(bare) = bare { + let without = both_run(&bare, 14, &[(2, edit(true), path)]); + assert!( + (6..14).any(|block| without[block] != composed[block]), + "{label}: the route into the output plays while t1 is muted" + ); + } + } +} From 259f6dd0ca1d70511b681b57781e3a05cb2f3659 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 10:03:07 +0000 Subject: [PATCH 18/23] Renumber placeholder TBD51 as #1713 (filed by root) The spec becomes 1713-carry-a-following-routes-column-moves-exactly- into-a-six-word-successor.md, filed by root from batch 18; the public doc text (C header, abi.rs, C ABI qualification, both .d.ts copies, still identical) and the code comments name #1713. Co-Authored-By: Claude Opus 5.5 --- ...follow-an-automated-mute-on-following-sends-in-render.md | 6 +++--- ...outes-column-moves-exactly-into-a-six-word-successor.md} | 4 ++-- crates/capi/include/miso_engine_v1.h | 2 +- crates/capi/src/abi.rs | 2 +- crates/capi/src/runtime/live_tests.rs | 2 +- crates/control-plane/src/control.rs | 4 ++-- crates/host-core/src/prepare.rs | 4 ++-- docs/C_ABI_V1_QUALIFICATION.md | 2 +- hosts/host-web/tests/control_parity.rs | 2 +- hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts | 2 +- sdk/src/browser/shipped-host.d.ts | 2 +- 11 files changed, 16 insertions(+), 16 deletions(-) rename .github/ISSUE_SPECS/{TBD51-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md => 1713-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md} (94%) diff --git a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md index 069415a3a..f6fbbdfea 100644 --- a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md +++ b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md @@ -716,7 +716,7 @@ ramps). Files: `crates/graph/src/{lib.rs,runtime.rs}`, `crates/lane/src/kernels. Fold of the U2a fold-1 verification (FAIL narrow at `977d6969c`), the last fold round for #1652. Root (2026-10-17) accepted the plan as written: MAJOR-A is a conservative flag, gated both ways, -the exact form is TBD51 (phase 2); MINOR-B must land. +the exact form is #1713 (phase 2); MINOR-B must land. - **MAJOR-A (seven -> six re-times a live move and reported EXACT).** Two render forms, both only on a carry of a following route into a six-word route: (a) `finish_six_word_carry` reopens a @@ -731,8 +731,8 @@ the exact form is TBD51 (phase 2); MINOR-B must land. run that one commit. The flag does not open B17-R14's level-move window (it is not a level move: the `landing` fold now reads the level condition alone, which keeps B17-R13 tranche 5's rule as it was). **Conservative:** it over-reports on every rebuild that removes a strip's last mute - automation while a following send of it exists, moving or not; TBD51 - (`.github/ISSUE_SPECS/TBD51-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md`) + automation while a following send of it exists, moving or not; #1713 + (`.github/ISSUE_SPECS/1713-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md`) gives the exact form and deletes the flag. Outcome doc text updated in `crates/capi/src/abi.rs`, `crates/capi/include/miso_engine_v1.h`, `docs/C_ABI_V1_QUALIFICATION.md` and the two shipped-host `.d.ts` copies. diff --git a/.github/ISSUE_SPECS/TBD51-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md b/.github/ISSUE_SPECS/1713-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md similarity index 94% rename from .github/ISSUE_SPECS/TBD51-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md rename to .github/ISSUE_SPECS/1713-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md index 24b47d6d8..d455a5865 100644 --- a/.github/ISSUE_SPECS/TBD51-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md +++ b/.github/ISSUE_SPECS/1713-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md @@ -1,7 +1,7 @@ # Carry a following route's column moves exactly into a six-word successor -Placeholder from batch 18 (#1652 U2a fold 2, MAJOR-A; root, 2026-10-17: "exact form not now", -phase 2). Root files it and gives it a number. Stateless: everything needed is below. +Filed by root from batch 18 (#1652 U2a fold 2, MAJOR-A; root, 2026-10-17: "exact form not now", +phase 2). Stateless: everything needed is below. ## Problem diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index cf231077a..4e3043ee5 100644 --- a/crates/capi/include/miso_engine_v1.h +++ b/crates/capi/include/miso_engine_v1.h @@ -183,7 +183,7 @@ extern "C" { * transition, or a Reposition swap while a live level move may still ramp (conservative: it can be * set when the move was not stretched; #1711 removes it), or a rebuild that carries a send * following a stored mute into one that follows none (conservative: it can be set when no send - * moves at the swap; TBD51 removes it). The host + * moves at the swap; #1713 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 ff41b8df7..8f0087200 100644 --- a/crates/capi/src/abi.rs +++ b/crates/capi/src/abi.rs @@ -96,7 +96,7 @@ pub const OUTCOME_EXACT: u64 = 1; /// 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; #1711 removes it), or a /// rebuild that carries a send following a stored mute into one that follows none (conservative: -/// it can be set when no send moves at the swap; TBD51 removes it). +/// it can be set when no send moves at the swap; #1713 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 c84a0c989..bc0a16421 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -11072,7 +11072,7 @@ fn a_following_send_leaks_nothing_of_a_stored_mute() { ); } -/// #1652 U2a fold 2 (MAJOR-A, conservative; TBD51 deletes the flag). A rebuild whose send join +/// #1652 U2a fold 2 (MAJOR-A, conservative; #1713 deletes the flag). A rebuild whose send join /// carries a following route into a six-word one (it removes `eq0`'s mute entry while `eq0-b` /// follows it) completes as the transition fallback: the C ABI watermark that covers it reads /// `OUTCOME_TRANSITION_FALLBACK`, count 1. The removal is sent at block 8 (sample 1,024), while the diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 74fdadf92..68562f678 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -2535,7 +2535,7 @@ where revision_gate, master_track: _, } = prepared_runtime; - // #1652 U2a fold 2 (MAJOR-A; TBD51 deletes it): read before the inventory moves into the + // #1652 U2a fold 2 (MAJOR-A; #1713 deletes it): read before the inventory moves into the // candidate's provider epoch. let follow_into_six_word = inventory.carries_a_following_route_into_a_six_word_route(); let mut candidate_provider = ProviderEpoch::candidate( @@ -2627,7 +2627,7 @@ where // fader row alone (#1651 T2, root ruling R18-4): a mute cell's adoption never stretches a // live move, so a successor that automates only a mute row completes `Exact`. // - // #1652 U2a fold 2 (MAJOR-A; TBD51 deletes this): a successor whose send join carries a + // #1652 U2a fold 2 (MAJOR-A; #1713 deletes this): a successor whose send join carries a // following route into a six-word route (the rebuild removes a strip's last mute entry // while a following send of it exists) may re-time a carried column's move, which render // cannot report: one ramp of the six-word route takes the session mute length for a join diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 46b13f5aa..892a65dc0 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -723,7 +723,7 @@ impl PlanStateInventory { /// mute entry while a following send of it exists. `false` for a plan prepared without a /// predecessor and for a declared discontinuity, which carries nothing. /// - /// **Conservative (#1652 U2a fold 2, MAJOR-A; TBD51 removes it).** A six-word successor holds + /// **Conservative (#1652 U2a fold 2, MAJOR-A; #1713 removes it).** A six-word successor holds /// one ramp, so render may re-time a carried column's move: a join record composed with the /// heard curve reopens the route over the session mute length `J` instead of the record's own /// length, and two columns' moves fold into one ramp over the longer remaining length. The @@ -5932,7 +5932,7 @@ fn resolve_route_carry<'a>( else { continue; }; - // #1652 U2a fold 2 (MAJOR-A; TBD51 deletes it): a following route carried into a + // #1652 U2a fold 2 (MAJOR-A; #1713 deletes it): a following route carried into a // six-word one holds one ramp, so render may re-time a column's move (a record composed // with the heard curve reopens over `J`, not its own length; two columns' moves fold into // one ramp over the longer remaining length). The join cannot tell whether a move is in diff --git a/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index 15526d738..e0001c6ef 100644 --- a/docs/C_ABI_V1_QUALIFICATION.md +++ b/docs/C_ABI_V1_QUALIFICATION.md @@ -153,7 +153,7 @@ untouched, and the host retries. In this slice every revision completes as `exac also reports a Reposition swap while a live level move may still ramp (conservative: it can be set when the move was not stretched; #1711 removes it; root ruling B17-R14), and a rebuild that carries a send following a stored mute into one that follows none (conservative: it can be set when no -send moves at the swap; TBD51 removes it; #1652 U2a fold 2). The frozen exported +send moves at the swap; #1713 removes it; #1652 U2a fold 2). 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 675a326e1..92ad65cc6 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -5314,7 +5314,7 @@ fn a_following_send_leaks_nothing_of_a_stored_mute_on_both_hosts() { ); } -/// #1652 U2a fold 2 (MAJOR-A, conservative; TBD51 deletes the flag) on both hosts. A rebuild +/// #1652 U2a fold 2 (MAJOR-A, conservative; #1713 deletes the flag) on both hosts. A rebuild /// whose send join carries a following route into a six-word one (it removes `t1`'s mute entry /// while `t1-b` follows it, sent at block 8 while the send's columns ramp toward the mute the /// curve stepped on at 1,000) completes as the transition fallback; a rebuild that keeps the 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 a6599e924..cf23f3e5c 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 @@ -782,7 +782,7 @@ export interface MisoStatus { /// `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; #1711 removes /// it), and a rebuild that carries a send following a stored mute into one that follows none - /// (conservative: it can be set when no send moves at the swap; TBD51 removes it). + /// (conservative: it can be set when no send moves at the swap; #1713 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 a6599e924..cf23f3e5c 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -782,7 +782,7 @@ export interface MisoStatus { /// `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; #1711 removes /// it), and a rebuild that carries a send following a stored mute into one that follows none - /// (conservative: it can be set when no send moves at the swap; TBD51 removes it). + /// (conservative: it can be set when no send moves at the swap; #1713 removes it). /// @internal readonly appliedOutcome: bigint; /// The module's memory size now: at least the boot size (`MisoAudioWorkletHost.memoryBytes`), From 6bf89866782c73bc4b88695e7c44478a864b3556 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 10:08:11 +0000 Subject: [PATCH 19/23] Fold the U1 review into stored mute automation (#1651, fold 1) - MAJOR-2: a hand or VCA mute committed with a mute curve rebuild lands as committed. A cell that owes a reposition composes the record with the curve at the block's first node time and applies SetMute over the record's own ramp; the adoption landing then pushes only a change the curve itself makes, over M. R18-4 is withdrawn for the strip; the records say why the adoption now lands as committed. - MAJOR-1: an in-block curve unmute ends an older fader-cell hold (release node; shorten-only CellState::release_hold_by in automation, path approved by root), so a ride under an offsets hold no longer freezes when the mute length is 0. - MINOR-3: a mute-curve rebuild with a live fader move in flight completes Exact on both hosts. MINOR-4: a composed stored mute and a seek render the same bits on both hosts. MINOR-5: the hold-release gate frees the lane with the production trigger (an exact unmute). Co-Authored-By: Claude Opus 5.5 --- .../1651-render-stored-mute-automation.md | 132 +++++- crates/automation/src/events.rs | 11 + crates/automation/tests/events.rs | 43 ++ crates/builtins-compiler/src/lib.rs | 67 +++- crates/control-plane/src/control.rs | 6 +- .../tests/mute_automation_realtime.rs | 92 ++++- .../tests/stored_fader_automation.rs | 67 ++-- .../host-core/tests/stored_mute_automation.rs | 187 ++++++++- hosts/host-web/tests/common/mod.rs | 9 +- hosts/host-web/tests/control_parity.rs | 376 +++++++++++++++++- 10 files changed, 898 insertions(+), 92 deletions(-) diff --git a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md index 4aee5abc7..3535bb515 100644 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md @@ -389,9 +389,13 @@ PR #1712). - Any rendered row, outside T2's files: `prepare_fader_automation`'s early return (`builtins-compiler/src/lib.rs:2095`). It is right for T3a (the program then also builds mute cells); until T3a it is the same predicate. -- **R18-4 (root, accepted).** B17-R14's window stays fader-only because a mute adoption never - stretches a live move: a mute cell's adoption jumps over the mute jump length and has no level - ramp in flight to land over `J`, so a successor that automates only mute rows completes `Exact`. +- **R18-4 (withdrawn by root at U1 fold 1).** There is no general claim that a mute adoption never + stretches a move: the U1 review found a hand or VCA mute committed in the same transaction as a + mute curve edit landing over the mute length instead of its own ramp. B17-R14's window stays + fader-only because a mute cell's adoption lands only a `SetMute`, never a level move over `J`, + and the U1 fold 1 fix makes that adoption land as committed: a terms record at the swap block + lands over its own ramp word, and the adoption landing adds only the curve's own change over the + mute length. So a successor that automates only mute rows completes `Exact`. - **Gate.** `crates/controls/tests/automation.rs::a_mute_entry_reaches_no_fader_only_reader`: a `both` mute entry before a `left` fader entry on `vocal`; the fader cells are the fader entry's left lane only, `automated_fader_lanes == [true, false]`, the right lane has no hand offset and @@ -655,7 +659,8 @@ PR #1712). - `LaneFaderAutomation::mute_terms`: on a cell that owes a reposition (and has node time this block) the drain only stores `terms`; the driver composes them with the curve at the swap block, so the swap lands the one `SetMute` the two changes owe, not one from the prepared - seed and a second that reverses it. + seed and a second that reverses it. (Superseded by U1 fold 1: the drain composes the record + with the curve at the block's node time and applies it over its own ramp word.) - `controls::continued_mute_lanes` (new, `crates/controls/src/automation.rs`; the fader and the mute rule share `continued_lanes`). host-core's strip join sets bits 2 and 3 of `continued_cells`; graph passes the byte unchanged (doc only). @@ -665,12 +670,14 @@ PR #1712). - **host-core `prepare.rs` functions touched (strip join only):** the `StripJoin` struct's `continued_cells` doc, `join_strips` (bits 2 and 3), and `EffectiveDiff::join_level_records` (the mute release beside the fader release). `resolve_route_carry` is not touched. -- **R18-4 for the strip (argument).** A mute adoption never stretches a live fader move: the - landing is a `SetMute`, which retargets only the lane's gain ramp toward `0` (or the - remembered gain) of the one lane and channel it addresses (dual-mono lanes have separate - stages), and the stage keeps `fader_gain` as the target the live move aimed for (#1638 D2, - D1.6), so after the unmute the lane is back at that gain. No level ramp is carried over `J`, - so B17-R14's window stays fader-only. Gate: below. +- **R18-4 for the strip (withdrawn by root at U1 fold 1; corrected text).** The landing is a + `SetMute`, which retargets only the lane's gain ramp toward `0` (or the remembered gain) of the + one lane and channel it addresses (dual-mono lanes have separate stages), and the stage keeps + `fader_gain` as the target the live move aimed for (#1638 D2, D1.6), so after the unmute the + lane is back at that gain. No level ramp is carried over `J`, so B17-R14's window stays + fader-only. The adoption lands as committed only with the U1 fold 1 fix: before it, the drain of + a repositioned cell dropped a same-transaction terms record's ramp word and the landing always + ramped over the mute length (see "Decision record: U1 fold 1"). Gate: below. - **Gates (all pass)**, `crates/host-core/tests/stored_mute_automation.rs`, every bank width (Simd8 on AVX2, Simd4) and the scalar oracle, each with and without live controls, quanta 128 and 100, a swap at block 8, a signed never-zero source; each case compares, plane by plane, @@ -703,7 +710,8 @@ PR #1712). bank widths only (its scalar leg failed on "one length for the strip and the send": the strip is set at 768). Carrying a bound fader node is outside this slice. - The adoption landing ramps over the block's mute jump length even when a `terms` record of - another length arrives at the same block (the two changes land as one `SetMute`). + another length arrives at the same block (the two changes land as one `SetMute`). (Defect, + U1 review MAJOR-2; fixed by U1 fold 1.) - A cell that owes a reposition on a lane with no node time keeps it owed; a `terms` record there applies at once, composed with the carried target (no curve renders on that lane). - **Measured.** `audit capi` `pcm_digest` `bffc21e857b5e134` (unchanged), allocations 0, @@ -716,3 +724,105 @@ PR #1712). `-p graph --features test-support`, `-p controls --all-features`; fmt, clippy (builtins-compiler, controls, host-core, graph; all targets and features, `-D warnings`) and the no-feature `cargo check --all-targets`. + +## Decision record: U1 fold 1 (batch 18) + +Fold round 1 of the U1 review (FAIL at `1d2e2bcc2`). Root rulings with it: MAJOR-2 takes the exact +fix (no fallback flag); R18-4 is withdrawn for the strip too (T2 and T4 text corrected above); +MAJOR-1 may extend the authorized paths to `crates/automation/src/events.rs`. + +- **MAJOR-2 (a terms record in a curve rebuild lands as committed).** + `LaneFaderAutomation::mute_terms` (`crates/builtins-compiler/src/lib.rs`): the drain stores + `terms`; when the record changes the stage mute the curve the stage heard gives (the old rule: + `target || terms` differs from the stage's mute), a cell that owes a reposition and has node time + this block composes the record with the curve at the block's first node time + (`value_at(cell, node_time_at(span, 0))`, the value the driver's ramped landing aims at, since a + step curve is constant between discontinuities) and applies `SetMute` at the block boundary over + the record's ramp word. The driver's adoption landing then composes the same value with the stage's + updated mute and pushes only a change the curve itself makes, over the mute length. At most one + `SetMute` per channel at the swap block. A record that changes nothing against the heard curve is + left to the landing (the change, if any, is the curve's). No new field, branch-only. Also fixes + NIT-6 (a session seek in the swap block no longer delays the terms change: the record applies at + the block boundary). Not separately gated. +- **MINOR-3.** `hosts/host-web/tests/control_parity.rs::a_mute_curve_rebuild_while_a_level_move_ramps_completes_exactly_on_both_hosts` + (gate 8's sentence): a mute-only session (a mute curve on `t1`), a hand fader move over 4,000 in + flight, then a mute-curve rebuild: `EXACT` and `transition_fallback_count == 0` on both hosts, + the same watermark and bits, at every launch rate. New helper `CAbi::transition_fallback_count`. +- **MINOR-4.** `control_parity.rs::a_composed_stored_mute_renders_alike_on_both_hosts` + (`test-support`): gate 3's script (solo on `t2`, a VCA mute of `t1` through a new VCA `m`, a hand + mute of `t1`; set while the curve is open, released while closed, while open, or kept) and a plain + session seek to 2,600 (inside the muted span) after the opening; the same verdicts and reply + bytes and the same bits per block on the browser and the C ABI, at quanta 128 and 37; the muted + span is heard (differs from a twin with no curve). The send does not follow `t1`'s mute here: see + the phase-2 line. Helpers: `CAbi::with_limits`, `submit_planes`, `render_frames` (the existing + `new`, `submit_from`, `render` call them) and `Rig::boot_at` in `hosts/host-web/tests/common/mod.rs` + (outside the listed paths: a mechanical test-helper extension; `Rig::boot` calls it). +- **MINOR-5.** `crates/host-core/tests/stored_fader_automation.rs::a_live_write_that_frees_a_held_lane_releases_its_hold` + now frees the lane with the production trigger: `Mute { true, 0 }` at the block entry before + 6,400 and `Mute { false, 0 }` at 6,400 (an exact hand unmute of a muted lane, `held_through` set), + at quanta 128, 100 and 64 and on the oracle (per quantum: the muted block differs between + quanta). `live_fader` is now unused and deleted. The other two R17-10 gates keep their + `FaderDb` form. +- **MAJOR-1 (a curve unmute ends an older cell hold).** Path extension approved by root: + `automation::CellState::release_hold_by(t)` (`crates/automation/src/events.rs`, shorten-only: + `ramp_done = t` only when a ramp is in flight and completes after `t`). In the driver, + `FaderCellCursor::rehold`'s unmuted branches record a release node (`node(at)` for `M = 0`, + `node(at + M) - 1` otherwise) and `FaderCellCursor::open` applies it to the cell's state before + it builds the next piece's events, also when no piece is left in the block (an unmute on its last + sample). Not an unconditional `hold_until`. The T3b phase-2 placeholder (an offsets-jump hold not + shortened by an in-block curve unmute) is this defect and is closed by it. +- **Gates (all pass).** + - MAJOR-2: `crates/control-plane/tests/mute_automation_realtime.rs::a_hand_mute_committed_with_a_mute_curve_edit_lands_as_committed` + (the review's repro through the shared commit: one transaction = `UpsertAutomation` moving + `eq0`'s closing step 1,000 -> 1,100 + `SetControl` mute = 1 with `ramp_samples` none / `0` / + `2,000`; path `Rebuild`; every block equals the live twin, no adoption offset) and, at the bank + widths, `crates/host-core/tests/stored_mute_automation.rs::a_hand_mute_in_a_curve_rebuild_lands_over_its_own_ramp` + (Simd8 on AVX2 and Simd4, live forms, quanta 128 and 100, ramps `M` / `0` / `2,000`: the + successor's first block takes the record; every sample equals the never-swapped twin; the swap + block allocates nothing; `carry_run` now passes the successor's live handles and gate to `edit` + from the swap block on). The scalar oracle restarts its strip at a swap (T4 departure), so it + has no leg. + - MAJOR-1: `stored_mute_automation.rs::a_curve_unmute_ends_an_older_hold_of_the_fader_cell` (the + review's probe: a ride `-30 -> 0` dB, a hand mute at 512, a hand unmute over 4,000 at 1,024, an + offsets write under it at 1,536, a curve mute 2,001 to 2,570; `M = 0` and `M = 48`; every live + form incl. the scalar oracle; quanta 128, 100, 37: each completion sample after `E + J` equals + the composed curve) and `crates/automation/tests/events.rs::a_released_hold_ends_early_and_is_never_lengthened`. + - Every mute gate (13 in `stored_mute_automation.rs`) and fader gate (30 in + `stored_fader_automation.rs`) green. +- **Red checks (temporary, reverted).** + - MAJOR-2 control-plane gate on the base `lib.rs`: red at `Some(0)`, block 4, and at `Some(2000)`, + block 4 (`None` is green, as the review found). Bank-width gate on the base: red at ramps `0` + and `2,000`, Simd8 and Simd4, both planes, quanta 128 (first difference 1,024) and 100 (800). + - MINOR-3: `automates` mutated to `automation_row_renders` -> outcome flags 2 (transition + fallback) instead of 1 at 44.1 kHz. + - MINOR-4: both hosts run one shared core, so a shared-code mutation moves both legs alike; a + browser-only mutation (the adapter's plain session seek anchored one block late) is red at the + seek ("block 36 bits", quantum 128). The gate is green on the pre-fold code. + - MINOR-5: no release (`if false && release` in the driver) -> red at 6,463 (quantum 128, bank). + - MAJOR-1 driver gate: the release removed (`self.release = None`) and the base `lib.rs`: red at + `M = 0` on every form and quantum (first completion sample 2,879: the ride frozen at -23.7 dB). + Unit test: an empty `release_hold_by` -> red (hold to 1,000 kept); `hold_until` in its place + -> red (hold to 200 lengthened to 500). +- **Departures.** + - MAJOR-1 at `M = 48` is not red on the base, and cannot be made red: the review's probe itself + shows no freeze at `M = 48` on the base (the grid events of the held span leave a move owed at + `E`, whose landing re-commits the cell and replaces the old hold). A freeze at `M > 0` needs + nothing owed at an in-block `E` with the old hold set after the last remembered event, that is + no grid point in `[X, E)` where `X` is the offsets write's entry at or before the curve mute; + automation discontinuities are at least 64 samples apart, so `E - X > 64` and a grid event + always lies there (the held branch clears the target, so it emits and owes). The `M = 48` leg + stays in the gate as a claim (green on both). + - MAJOR-2's gate in host-core runs the bank widths only (no scalar leg: T4 departure). +- **Measured.** `audit capi` `pcm_digest` `bffc21e857b5e134` (unchanged), allocations 0, + `total_violations` 0. `realtime_policy`: ok, 168 marked regions (unchanged). +- **Ran (all pass).** `cargo test --locked` on `-p builtins-compiler --features test-support` + (87), `-p automation --all-features` (32), `-p host-core --features + host-core/test-support,host-core/control-provider` (581), `-p capi` (231), `-p host-web --features + host-web/test-support` (214), `-p control-plane --features test-support` (28). fmt, clippy + (automation, builtins-compiler, control-plane, host-core, host-web; all targets and features, + `-D warnings`) and the no-feature `cargo check --all-targets` of the same crates and `capi`. +- **Phase-2 placeholder.** A solo on `t2` in a session where `t1` has a mute curve and a send that + follows `t1`'s mute is refused by the browser with `control.internal` (seen while writing + MINOR-4; the solo moves a following route's gate, which is a rebuild until #1653, and a monitor + write cannot take a rebuild). Not investigated further; #1653's scope. + diff --git a/crates/automation/src/events.rs b/crates/automation/src/events.rs index 357773faa..5bed33c9e 100644 --- a/crates/automation/src/events.rs +++ b/crates/automation/src/events.rs @@ -145,6 +145,17 @@ impl CellState { self.ramp_done = completion; } + /// Shortens the hold of the ramp in flight so that it completes no later than node time `t`: + /// the caller's stage stopped holding the lane earlier than the hold it reported (a ramp that + /// a later operation ended). Shorten-only: a hold that already completes by `t`, or no ramp in + /// flight, is left as it is: it never lengthens a hold or starts one, as + /// [`Self::hold_until`] would. + pub const fn release_hold_by(&mut self, t: i64) { + if self.ramp_done != IDLE && self.ramp_done > t { + self.ramp_done = t; + } + } + /// 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. diff --git a/crates/automation/tests/events.rs b/crates/automation/tests/events.rs index ad3cf00fc..06576382e 100644 --- a/crates/automation/tests/events.rs +++ b/crates/automation/tests/events.rs @@ -131,6 +131,49 @@ fn held_inside_a_reported_ramp_and_the_grid_resumes() { assert_eq!(Some(last.value), state.target()); } +/// #1651 U1 fold 1 (MAJOR-1): `release_hold_by` shortens a reported hold and never lengthens or +/// starts one. A hold to 1,000 released by 127 holds only the grid ramp that completes before 127 +/// (the one at 0), as a hold reported to 127 would; a release by 500 of a hold to 200 keeps 200 +/// (the ramps at 0, 64 and 128); a release of a state with no ramp in flight starts none. +/// +/// Red if the release is ignored (the grid stays held to 1,000), if it lengthens a shorter hold +/// (`hold_until` in its place: the grid stays held to 500), or if it starts a hold on a free cell. +#[test] +fn a_released_hold_ends_early_and_is_never_lengthened() { + let program = fader(&[seg(0, 100_000, 0.0, 100_000.0, Shape::Linear)]); + let held = |prepare: &dyn Fn(&mut CellState)| { + let mut state = CellState::new(); + prepare(&mut state); + run(&program, &mut state, 0, 640, 1, SeekMode::Exact, 480) + .iter() + .map(|(t, e)| (*t, e.held)) + .filter(|&(_, held)| held) + .map(|(t, _)| t) + .collect::>() + }; + assert_eq!( + held(&|state| { + state.hold_until(1_000); + state.release_hold_by(127); + }), + [0], + "a hold to 1,000 released by 127" + ); + assert_eq!( + held(&|state| { + state.hold_until(200); + state.release_hold_by(500); + }), + [0, 64, 128], + "a hold to 200 released by 500 keeps 200" + ); + assert_eq!( + held(&|state| state.release_hold_by(500)), + Vec::::new(), + "no hold in flight: none starts" + ); +} + /// Red if a held event inside a ramp is dropped as unchanged while the stage still remembers an /// older held value. #[test] diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 97321ef71..dfe16f6f8 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -1053,7 +1053,10 @@ impl LaneFaderAutomation { /// stage mute `curve || terms` with the curve state the stage has heard (the cell's current /// target); when that differs from the stage's mute it applies `SetMute` at the block /// boundary over the record's ramp word `ramp`, as [`Self::read_offsets`] applies its jump. - /// The block's curve events then compose with the new `terms`. A channel with no mute cell + /// On a cell that owes a reposition (#1651 T4) the same record composes with the curve at the + /// block's first node time instead, so it lands as committed and the driver's adoption + /// landing pushes only a change the curve itself makes (U1 fold 1). The block's curve events + /// then compose with the new `terms`. A channel with no mute cell /// is not this cell's (the caller applies its record to the stage). Allocation-, lock- and /// syscall-free: one compare and at most one operation. fn mute_terms( @@ -1067,15 +1070,29 @@ impl LaneFaderAutomation { return; }; cell.terms = terms; - // #1651 T4: a repositioned cell's target is not yet the curve at this block's node time - // (the driver repositions it below), so this block's driver composes the new `terms` with - // the curve there and lands the one `SetMute` the two changes owe. A lane with no node - // time this block renders no curve and keeps the cell owed, so the record applies here. - if cell.reposition && self.span.is_some() && self.mute_jump.is_some() { + let heard = cell.state.target().is_some_and(curve_mutes) || terms; + let stage_muted = stage.muted(channel); + if heard == stage_muted { return; } - let muted = cell.state.target().is_some_and(curve_mutes) || terms; - if stage.muted(channel) != muted { + // #1651 U1 fold 1 (MAJOR-2): a repositioned cell's target is not yet the curve at this + // block's node time (the driver repositions it below). The record changes the stage mute + // the curve the stage heard gives, so it lands here as committed, over its own ramp word, + // composed with the curve at the block's first node time; the driver's landing then + // compares with that stage mute and pushes only a change the curve itself makes, over the + // mute length. So the swap block lands at most one `SetMute` per channel. A lane with no + // node time this block renders no curve and keeps the cell owed: the carried target is + // the curve it holds. + let at_node = match (cell.reposition, self.mute_jump, self.span) { + (true, Some(_), Some(span)) => node_time_at(span, 0) + .zip(self.program.cell(cell.cell as usize)) + .map(|(time, program)| { + curve_mutes(automation::value_at(program.segments(), time)) || terms + }), + _ => None, + }; + let muted = at_node.unwrap_or(heard); + if stage_muted != muted { let applied = stage.apply( channel, builtins::FaderOperation::SetMute { @@ -1467,6 +1484,10 @@ struct FaderCellCursor<'a> { end: Option, /// The offset of the channel's next mute event, while one is ahead (#1651 T3b). mute_at: Option, + /// The node time by which the cell's hold must end, set by an unmute the driver pushed + /// ([`Self::rehold`]) and applied to the cell's state where the next piece opens + /// ([`Self::open`]), shorten-only (#1651 U1 fold 1, MAJOR-1). + release: Option, /// The block's node-time mapping: offset 0's node time and the seek step, and its length. start: i64, step: Option<(u32, i64)>, @@ -1541,8 +1562,19 @@ impl FaderCellCursor<'_> { /// [`Self::piece_last`]. `false` when there is none: `from` is past the block, or a refusal /// (unreachable: the block's node times were checked against the `i64` edge, and a step kept /// inside the piece lies inside it), which leaves the cell's state where it was. + /// + /// A release [`Self::rehold`] recorded is applied to the cell's state first, also when no + /// piece is left (an unmute on the block's last sample): the next block's events start from + /// that state. fn open(&mut self, from: u32) -> bool { if from >= self.frames { + // The block is spent: no later piece reads the events, and the cell's state lives + // in its lane slot. + if let Some(release) = self.release.take() + && let Some(events) = self.events.take() + { + events.into_state().release_hold_by(release); + } return false; } let last = self.piece_last(from); @@ -1555,9 +1587,13 @@ impl FaderCellCursor<'_> { }; self.base = from; self.last = last; + let state = events.into_state(); + if let Some(release) = self.release.take() { + state.release_hold_by(release); + } self.events = Some(automation::CellEvents::new( self.program, - events.into_state(), + state, span, controls::FADER_CELL_RULE.seek, self.jump, @@ -1575,6 +1611,13 @@ impl FaderCellCursor<'_> { /// the next block's to find from the stage. Unmuted exactly (`M = 0`): the stage takes the /// remembered gain, the cell's committed target, at `at`, so the gain is free and nothing is /// owed. An older cut ahead is dropped: the stage's hold no longer ends there. + /// + /// #1651 U1 fold 1 (MAJOR-1): an unmute also ends a hold the cell holds of an older ramp (an + /// offsets jump's on a lane held by a mute ramp: until that ramp's end at its block's entry), + /// which the stage no longer holds: the cell's hold is shortened to end at `node(at)` for an + /// exact unmute and at `node(at + M) - 1` otherwise, so its grid moves the lane from the first + /// free sample instead of being only remembered on a free stage. Shorten-only: a hold of the + /// cell's own that ends earlier is kept. fn rehold(&mut self, at: u32, muted: bool, samples: u32) { self.end = None; let frames = self.frames; @@ -1585,6 +1628,11 @@ impl FaderCellCursor<'_> { obligation.held_through = true; return; } + self.release = match samples { + 0 => span_time(self.start, self.step, u64::from(at)), + samples => span_time(self.start, self.step, u64::from(at) + u64::from(samples)) + .map(|end| end.saturating_sub(1)), + }; if samples == 0 { self.free_from = 0; obligation.owed = false; @@ -1908,6 +1956,7 @@ impl<'a> FaderEventDriver<'a> { free_from, end: end.flatten(), mute_at, + release: None, start: span.start, step, frames, diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index e51c15754..514966541 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -2602,8 +2602,10 @@ where // 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. The test is the - // fader row alone (#1651 T2, root ruling R18-4): a mute cell's adoption never stretches a - // live move, so a successor that automates only a mute row completes `Exact`. + // fader row alone (#1651 T2; U1 fold 1, R18-4 withdrawn): a mute cell's adoption lands + // only a `SetMute` (the curve's change over the mute length, a terms record committed in + // the same transaction over its own ramp), never a level move over `J`, so a successor + // that automates only a mute row completes `Exact`. let (outcome, rebuild_level) = match &kind { Rebuild::Transaction(prepared) => { let session = prepared.get().prospective_session(); diff --git a/crates/control-plane/tests/mute_automation_realtime.rs b/crates/control-plane/tests/mute_automation_realtime.rs index 2f8dc03d5..12258954f 100644 --- a/crates/control-plane/tests/mute_automation_realtime.rs +++ b/crates/control-plane/tests/mute_automation_realtime.rs @@ -97,11 +97,9 @@ fn id(text: &str) -> StableId { StableId::parse(text).expect("stable ID") } -/// The fixture with VCA `v` holding `eq0` and, with `curve`, `eq0`'s mute driven by a step curve -/// on both lanes, closed over `1_000..2_500`. -fn document(curve: bool) -> (String, u64) { - let mut model = session::parse_session_json(SESSION).expect("fixture"); - let frames = model.sources[0].frames; +/// `eq0`'s mute driven by a step curve on both lanes, closed over `close..2_500` of a source of +/// `frames` frames. +fn curve(close: u64, frames: u64) -> Automation { let step = |start_sample, end_sample, value| AutomationSegment { shape: AutomationShape::Step, start_sample, @@ -110,7 +108,7 @@ fn document(curve: bool) -> (String, u64) { end_value: value, unit: ParameterUnit::Linear, }; - model.automation.extend(curve.then(|| Automation { + Automation { id: id("eq0-mute-curve"), target: ControlAddress { owner: Owner::Strip(id("eq0")), @@ -119,12 +117,22 @@ fn document(curve: bool) -> (String, u64) { lane: ParameterChannel::Both, }, segments: vec![ - step(0, 1_000, 0.0), - step(1_000, 2_500, 1.0), + step(0, close, 0.0), + step(close, 2_500, 1.0), step(2_500, frames, 0.0), ], offset: AutomationOffset::default(), - })); + } +} + +/// The fixture with VCA `v` holding `eq0` and, with `curve`, `eq0`'s mute driven by a step curve +/// on both lanes, closed over `1_000..2_500`. +fn document(with_curve: bool) -> (String, u64) { + let mut model = session::parse_session_json(SESSION).expect("fixture"); + let frames = model.sources[0].frames; + model + .automation + .extend(with_curve.then(|| curve(1_000, frames))); model.vcas.push(Vca { id: id("v"), fader: DualMonoFader { @@ -214,13 +222,19 @@ impl Rig { /// Commit `value` at `address` live (the session's ramp). fn set(&mut self, address: ControlAddress, value: f32) { - self.request += 1; let edits = [SessionEdit::SetControl { expected_effect: None, address, value, ramp_samples: None, }]; + let path = self.transact(&edits); + assert_eq!(path, EditPath::Live, "a mute term is live"); + } + + /// Commit `edits` as one transaction; its edit path. + fn transact(&mut self, edits: &[SessionEdit]) -> EditPath { + self.request += 1; let codec = ProtocolCodec::default(); let mut bytes = vec![0_u8; codec.limits().max_frame_bytes]; let length = codec @@ -228,7 +242,7 @@ impl Rig { &TypedCommandFrame { request_id: RequestId::new(self.request).expect("nonzero request"), expected_revision: ExpectedRevision::Exact(SessionRevision(self.revision)), - payload: CommandPayload::SessionTransactionApply(&edits), + payload: CommandPayload::SessionTransactionApply(edits), }, &mut bytes, ) @@ -255,9 +269,9 @@ impl Rig { } DecodedTypedResponseFrame::Success { .. } => panic!("another success payload"), }; - assert_eq!(path, EditPath::Live, "a mute term is live"); self.revision += 1; self.drain(); + path } /// The commit's reliable events, read as a host reads them. @@ -341,3 +355,57 @@ fn stored_mute_automation_renders_allocation_free() { } } } + +/// #1651 U1 fold 1 (MAJOR-2): a hand mute committed in the same transaction as a mute curve edit +/// lands as committed. At block 4 one transaction moves `eq0`'s closing step from 1,000 to 1,100 +/// (a carried rebuild: the curve's table changes, so the adopted cell repositions) and sets `eq0`'s +/// hand mute with `ramp_samples` absent (the session's mute length), `0` and `2,000`. A twin with +/// the old curve takes the hand mute alone, live, with the same ramp: the curve's change lies +/// inside the muted span the hand mute holds, so every block of the rebuilt run equals the twin's, +/// bit for bit, through the ramp and after the curve's opening (still held by the hand mute). +/// +/// Test value: red if the swap block's adoption drops the record's ramp word and lands the hand +/// mute over the mute length instead (a `0` step becomes a 480-sample ramp, a 2,000-sample fade +/// becomes 480), or lands it twice. +#[test] +fn a_hand_mute_committed_with_a_mute_curve_edit_lands_as_committed() { + let frames = session::parse_session_json(SESSION) + .expect("fixture") + .sources[0] + .frames; + let hand = ControlAddress { + owner: Owner::Strip(id("eq0")), + section: Section::Builtins, + control: 6, + lane: ParameterChannel::Both, + }; + for ramp_samples in [None, Some(0), Some(2_000)] { + let mut rig = Rig::new(true); + let mut twin = Rig::new(true); + let mute = SessionEdit::SetControl { + expected_effect: None, + address: hand.clone(), + value: 1.0, + ramp_samples, + }; + for block in 0..40_u64 { + if block == 4 { + let path = rig.transact(&[ + SessionEdit::UpsertAutomation { + automation: curve(1_100, frames), + }, + mute.clone(), + ]); + assert_eq!(path, EditPath::Rebuild, "{ramp_samples:?}: the curve edit"); + let path = twin.transact(std::slice::from_ref(&mute)); + assert_eq!(path, EditPath::Live, "{ramp_samples:?}: the hand mute"); + } + rig.block(); + twin.block(); + assert_eq!( + rig.last, twin.last, + "{ramp_samples:?}: block {block} equals the hand mute alone" + ); + } + } +} diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index ddb1ece5e..fdb1333e6 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -1872,27 +1872,6 @@ fn live_mute(rig: &mut Rig, lanes: builtins::BuiltinLaneSelector, muted: bool, w }); } -/// Write a live fader record on track 0's `lanes` (`db` over `window`), applied at the next block -/// entry. On a lane in an unmute ramp it ends the ramp: a repeated unmute does not, because a mute -/// that repeats the lane's state leaves the stage as it was (#1651, batch 13b). -fn live_fader(rig: &mut Rig, lanes: builtins::BuiltinLaneSelector, db: f32, 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::FaderDb { - lanes, - db, - smoothing_samples: window, - }, - ); - }); -} - /// Write a live offsets record `S = offsets_sum` (ramp `window`) on both channels of track 0, /// applied at the next block entry. fn live_offsets(rig: &mut Rig, offsets_sum: f64, window: u32) { @@ -2061,33 +2040,47 @@ fn fade_from(from: f32, end: u64) -> Vec { /// 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 8,000 samples is drained at 0, an offsets write /// `S = -6` dB (ramp 200) at 3,200 under that ramp (the held branch of the offsets jump: the cell -/// forgets its target and holds to 7,999; the fade's grid events after it leave a move owed), and -/// a live fader write of `-10` dB over window 0 at 6,400 ends the ramp (a second unmute would not: -/// a mute that repeats the lane's state changes nothing, #1651 batch 13b). From 6,400 the lane -/// follows the composed curve: at each completion sample `64k + 63` it equals +/// forgets its target and holds to 7,999; the fade's grid events after it leave a move owed). Then +/// the production trigger (#1651 U1 fold 1, MINOR-5): the shared commit's `Mute` records of a hand +/// mute and an exact hand unmute, `Mute { true, 0 }` at the block entry before 6,400 (the lane is +/// held through that block) and `Mute { false, 0 }` at 6,400, which frees the lane at that entry. +/// From 6,400 the lane follows the composed curve: at each completion sample `64k + 63` it equals /// `checked_fader_gain(vca_compose_db(curve, S))` 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 while the curve moves, until the hold's last grid point), or if the release is -/// made only when nothing is owed. +/// exact unmute left while the curve moves, until the hold's last grid point), or if the release +/// is made only when nothing is owed. #[test] fn a_live_write_that_frees_a_held_lane_releases_its_hold() { const FRAMES: u64 = 8_000; 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 { + let script = |rig: &mut Rig| match rig.rendered { 0 => live_mute(rig, builtins::BuiltinLaneSelector::Both, false, 8_000), 3_200 => live_offsets(rig, -6.0, 200), - 6_400 => live_fader(rig, builtins::BuiltinLaneSelector::Both, -10.0, 0), + 6_400 => live_mute(rig, builtins::BuiltinLaneSelector::Both, false, 0), + at if at + rig.quantum as u64 == 6_400 => { + live_mute(rig, builtins::BuiltinLaneSelector::Both, true, 0); + } _ => {} - }); - for plane in &out { - for t in (6_400 + 63..FRAMES).step_by(64) { - assert_eq!( - plane[t as usize].to_bits(), - composed(curve(&entry, t as i64), -6.0).to_bits(), - "completion sample {t}" - ); + }; + for quantum in [128, 100, 64] { + let mut at = model.clone(); + at.quantum_frames = quantum; + #[cfg_attr(not(feature = "test-support"), allow(unused_mut))] + let mut outs = vec![("bank", scripted(Rig::live(&at), FRAMES, &script))]; + #[cfg(feature = "test-support")] + outs.push(("oracle", scripted(oracle_rig(&at), FRAMES, &script))); + for (form, out) in &outs { + for plane in out { + for t in (6_400 + 63..FRAMES).step_by(64) { + assert_eq!( + plane[t as usize].to_bits(), + composed(curve(&entry, t as i64), -6.0).to_bits(), + "quantum {quantum}, {form}: completion sample {t}" + ); + } + } } } } diff --git a/crates/host-core/tests/stored_mute_automation.rs b/crates/host-core/tests/stored_mute_automation.rs index ee63b8d24..c1b9946e8 100644 --- a/crates/host-core/tests/stored_mute_automation.rs +++ b/crates/host-core/tests/stored_mute_automation.rs @@ -278,6 +278,28 @@ impl Rig { }); } + /// Write a live offsets record `S = offsets_sum` (ramp `window`) on both channels of track 0, + /// applied at the next block entry: the shared commit's record of a VCA fader move. + fn live_offsets(&mut self, offsets_sum: f64, window: u32) { + use builtins_compiler::TestOnlyWriteGate; + let handles = self.handles.as_mut().expect("live controls"); + self.gate + .as_mut() + .expect("the plan's gate") + .write_published(|stamp| { + for channel in 0..2 { + handles.strip_controls[0].fader.write_offsets( + stamp, + channel, + builtins_compiler::FaderOffsetsRecord { + offsets_sum, + smoothing_samples: window, + }, + ); + } + }); + } + /// Render one block; returns its first render sample. fn block(&mut self) -> u64 { let quantum = self.quantum; @@ -1101,7 +1123,9 @@ fn compiled(model: &SessionModel) -> host_core::CompiledSession { /// `a` prepared in `form` and rendered for [`SWAP_BLOCK`] blocks (`edit` runs before each block, /// writing through its live handles and gate), then, with `b = Some((next, committed))`, `next` /// prepared as its successor on the committed model `committed`, swapped in and rendered to -/// `frames`; with `b = None`, `a` alone for every block. Fed `pcm` from frame 0. +/// `frames` (`edit` then writes through the successor's handles and gate: a record written at the +/// swap block is one the shared commit wrote for the transaction that rebuilt); with `b = None`, +/// `a` alone for every block. Fed `pcm` from frame 0. fn carry_run( a: &SessionModel, b: Option<(&SessionModel, &SessionModel)>, @@ -1172,6 +1196,7 @@ fn carry_run( let mut swap_allocations = (0, 0, 0); let mut restarted = Vec::new(); let mut successor_sources = None; + let mut successor_live = None; let mut block = 0; while block * quantum < frames { if block == SWAP_BLOCK @@ -1181,7 +1206,7 @@ fn carry_run( let committed = compiled(committed).normalized_model().clone(); let revision = gate.as_ref().map_or(1, |gate| gate.last_published()) + 1; let base = host_core::SuccessorBase::new(&inventory, &committed, revision); - let mut prepared = if form.live { + let prepared = if form.live { host_core::test_only_prepare_host_runtime_with_live_controls_successor_on( &next, &caps, @@ -1189,7 +1214,7 @@ fn carry_run( base, form.backend, ) - .map(|(prepared, _)| prepared) + .map(|(prepared, handles)| (prepared, Some(handles))) } else { host_core::test_only_prepare_host_runtime_successor_on( &next, @@ -1197,8 +1222,10 @@ fn carry_run( base, form.backend, ) + .map(|prepared| (prepared, None)) } .unwrap_or_else(|failure| fail(failure)); + let (mut prepared, next_handles) = prepared; prepared.sources.adopt_persisting(&mut sources); restarted = prepared.restarted_strips().to_vec(); let PreparedHost { @@ -1206,9 +1233,9 @@ fn carry_run( sources: next_sources, .. } = prepared; - plan.take_revision_gate() - .expect("the successor's gate") - .publish(revision); + let mut next_gate = plan.take_revision_gate().expect("the successor's gate"); + next_gate.publish(revision); + successor_live = next_handles.map(|handles| (handles, next_gate)); publisher .reserve_replacement(plan, engine::realtime::PlanAdoption::Next) .unwrap_or_else(|_| panic!("reserve the successor")) @@ -1217,7 +1244,9 @@ fn carry_run( } let at = block * quantum; submit(successor_sources.as_mut().unwrap_or(&mut sources), at); - if let (Some(handles), Some(gate)) = (handles.as_mut(), gate.as_mut()) + if let Some((handles, gate)) = successor_live.as_mut() { + edit(block, handles, gate); + } else if let (Some(handles), Some(gate)) = (handles.as_mut(), gate.as_mut()) && successor_sources.is_none() { edit(block, handles, gate); @@ -1244,6 +1273,7 @@ fn carry_run( block += 1; } drop(handles); + drop(successor_live); CarryRun { out: out.map(|mut plane| { plane.truncate(frames); @@ -1432,7 +1462,7 @@ fn a_rebuild_that_changes_a_mute_curve_jumps_only_when_the_composed_mute_changes ); } -/// #1651 T4, R18-4: a live fader move in flight on track 0 across a rebuild that adds a curve +/// #1651 T4: a live fader move in flight on track 0 across a rebuild that adds a curve /// mute which is on at the swap. A `FaderDb` of -12 dB over 2,000 samples is written before block /// 5 and is still ramping at the swap (block 8); the successor's committed model holds the -12 /// dB and a mute curve muted until timeline 2,600. The adoption mutes the lane over `M` (dual-mono @@ -1517,3 +1547,144 @@ fn a_mute_adoption_keeps_the_fader_move_in_flight_for_the_unmute() { } } } + +/// #1651 U1 fold 1 (MAJOR-2), every bank width: a hand mute of track 0 committed in the same +/// transaction as a mute curve edit lands as committed. The predecessor's curve closes at 1,500 +/// and opens at 2,500; at block 8 (the curve open) a rebuild moves the closing step to 1,600 (the +/// adopted cell repositions) and the successor's committed model holds the hand mute on, whose +/// `Mute` record +/// (the shared commit's terms write, over the edit's ramp word: `M`, `0` or `2,000`) reaches the +/// successor's first block. A twin that never swaps takes the same record at the same block. The +/// curve's change lies inside the span the hand mute holds, so the swapped run equals the twin, +/// bit for bit, on every sample (live forms; the scalar oracle restarts its strip at a swap, so +/// it has no carry to hold). +/// +/// Test value: red if the swap block's adoption drops the record's ramp word and lands the hand +/// mute over the mute length instead (a `0` step becomes a 480-sample ramp, a 2,000-sample fade +/// becomes 480), or lands it twice. +#[test] +fn a_hand_mute_in_a_curve_rebuild_lands_over_its_own_ramp() { + for ramp in [M, 0, 2_000] { + let mute = |block: usize, + handles: &mut host_core::HostLiveControlHandles, + gate: &mut engine::realtime::PlanRevisionGate| { + use builtins_compiler::TestOnlyWriteGate; + if block == SWAP_BLOCK { + gate.write_published(|stamp| { + handles.strip_controls[0].fader.write( + stamp, + builtins_compiler::TrackFaderRecord::Mute { + lanes: builtins::BuiltinLaneSelector::Both, + muted: true, + smoothing_samples: ramp, + }, + ); + }); + } + }; + for quantum in CARRY_QUANTA { + let a = with_mute(quantum, Some((0.0, &[(1_500, 1.0), (2_500, 0.0)]))); + let mut b = with_mute(quantum, Some((0.0, &[(1_600, 1.0), (2_500, 0.0)]))); + (b.tracks[0].fader.left_mute, b.tracks[0].fader.right_mute) = (true, true); + let q = quantum as usize; + let frames = 4_096_usize.div_ceil(q) * q; + let pcm = carry_pcm(frames); + for form in carry_forms() + .into_iter() + .filter(|form| form.live && form.backend != graph_compiler::Backend::Scalar) + { + let what = format!("ramp {ramp}, {form:?}, quantum {quantum}"); + let twin = carry_run(&a, None, form, &pcm, frames, mute).out; + let swapped = carry_run(&a, Some((&b, &a)), form, &pcm, frames, mute); + assert_eq!( + swapped.swap_allocations, + (0, 0, 0), + "{what}: the swap block" + ); + assert!(swapped.restarted.is_empty(), "{what}: the strip carries"); + for (plane, (out, twin)) in swapped.out.iter().zip(&twin).enumerate() { + assert_eq!( + first_difference(out, twin), + None, + "{what}: plane {plane} equals the hand mute alone" + ); + } + } + } + } +} + +/// #1651 U1 fold 1 (MAJOR-1): a curve unmute ends the cell's hold of an older ramp. Track 0 rides +/// a linear fade from `-30` to `0` dB over `[0, 6_400]` and its mute curve closes at 2,001 and +/// opens at 2,570. A hand mute (exact) is drained at the entry at or after 512 and a hand unmute +/// over 4,000 samples at the entry at or after 1,024; under that unmute ramp an offsets write +/// `S = -6` dB (ramp 200) at the entry at or after 1,536 takes the held branch of the offsets +/// jump, which holds the cell until the stage's ramp end (about 5,023). The curve's mute at 2,001 +/// replaces the hand unmute ramp, and its unmute at 2,570 over the mute length `M` frees the stage +/// at `E = 2_570 + M`, long before that hold ends. With `M = 0` (`mute_ms` 0) and with `M = 48` +/// (`mute_ms` 1: below the 64-sample grid, with no grid point in `(2_570, E)`), from `E` plus `J` +/// 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 live form (Simd8 on AVX2, +/// Simd4, between render calls, the scalar oracle) at quanta 128, 100 and 37. +/// +/// Test value: red at `M = 0` if the unmute leaves the cell's older hold in place: the exact +/// unmute owes nothing, so its grid events after `E` are only remembered on a free stage and the +/// ride freezes at `-23.7` dB until the hold's end. At `M = 48` the grid events of the muted span +/// leave a move owed at `E`, whose landing re-commits the cell, so that leg holds the claim but is +/// green without the release too. +#[test] +fn a_curve_unmute_ends_an_older_hold_of_the_fader_cell() { + for mute_ms in [0.0_f32, 1.0] { + for quantum in [128, 100, 37] { + let mut model = plain_with(1, quantum, mute_ms); + let m = controls::LiveRamps::for_session(&model).mute_samples; + let ride = fader_entry( + &model, + 0, + &[(AutomationShape::Linear, 0, 6_400, -30.0, 0.0)], + ); + model.automation = vec![ + ride.clone(), + mute_entry( + &model, + 0, + ParameterChannel::Both, + 0.0, + &[(2_001, 1.0), (2_570, 0.0)], + ), + ]; + let free = 2_570 + u64::from(m); + let check_from = (free + u64::from(J)).div_ceil(64) * 64 + 63; + for form in forms().into_iter().filter(|form| form.live) { + let what = format!("M {m}, quantum {quantum}, {form:?}"); + let mut rig = Rig::new(&model, form); + let q = rig.quantum as u64; + while rig.rendered < 4_800 { + let entry = |at: u64| (at..at + q).contains(&rig.rendered); + if entry(512) { + rig.live_mute(true, 0); + } else if entry(1_024) { + rig.live_mute(false, 4_000); + } else if entry(1_536) { + rig.live_offsets(-6.0, 200); + } + rig.block(); + } + for plane in &rig.out { + for t in (check_from..4_800).step_by(64) { + let value = + automation::value_at(&controls::stored_segments(&ride), t as i64); + let expected = + builtins::checked_fader_gain(session::vca_compose_db(value, -6.0)) + .expect("fader domain"); + assert_eq!( + plane[t as usize].to_bits(), + expected.to_bits(), + "{what}: completion sample {t}" + ); + } + } + } + } + } +} diff --git a/hosts/host-web/tests/common/mod.rs b/hosts/host-web/tests/common/mod.rs index a82f69568..8ce30f3a5 100644 --- a/hosts/host-web/tests/common/mod.rs +++ b/hosts/host-web/tests/common/mod.rs @@ -361,10 +361,15 @@ impl Rig { } pub(crate) fn boot(document: &[u8], rate: u32) -> Self { + Self::boot_at(document, rate, QUANTUM) + } + + /// [`Self::boot`] at the render quantum `quantum` (the document's own). + pub(crate) fn boot_at(document: &[u8], rate: u32, quantum: u32) -> Self { let options = WebBootOptions { require_sample_rate_hz: rate, - require_quantum_frames: QUANTUM, - source_ring_frames: QUANTUM * 2, + require_quantum_frames: quantum, + source_ring_frames: quantum * 2, ..WebBootOptions::explicit_defaults() }; let host = AudioWorkletEngineHost::boot(document, options) diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index bec903f93..56ddadab8 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -920,6 +920,11 @@ fn id(text: &str) -> StableId { impl CAbi { fn new(model: &SessionModel) -> Self { + Self::with_limits(model, &limits()) + } + + /// [`Self::new`] under `limits`. + fn with_limits(model: &SessionModel, limits: &CompileLimits) -> Self { let document = canonical_session_json(model).expect("canonical session"); let config = EngineConfig { struct_size: ENGINE_CONFIG_SIZE, @@ -949,7 +954,7 @@ impl CAbi { engine, document.as_ptr(), document.len() as u64, - &limits(), + limits, &mut diagnostics, &mut session, &mut plan, @@ -1369,6 +1374,26 @@ impl CAbi { (out.revision, out.first_sample, out.outcome_flags) } + /// The plan's saturating count of revisions completed as a transition fallback, through + /// `miso_engine_v1_plan_watermark`. + fn transition_fallback_count(&self) -> u64 { + let mut out = capi::Watermark { + struct_size: capi::WATERMARK_SIZE, + reserved0: 0, + revision: 0, + first_sample: 0, + outcome_flags: 0, + exact_count: 0, + transition_fallback_count: 0, + superseded_count: 0, + reserved: [0; 5], + }; + // SAFETY: The plan is live; the query is pure and `out` is owned storage of the exact size. + let code = unsafe { capi::miso_engine_v1_plan_watermark(self.plan, &mut out) }; + assert_eq!(code, C_RESULT_OK, "C ABI watermark"); + out.transition_fallback_count + } + /// The active plan's `(latency_samples, tail_kind, tail_samples)` through /// `miso_engine_v1_plan_resources`. fn plan_latency(&self) -> (u64, u64, u64) { @@ -1431,15 +1456,33 @@ impl CAbi { generation: u64, ) { let [left, right] = planes(feed, block); - let pointers = [left.as_ptr(), right.as_ptr()]; + self.submit_planes( + name, + generation, + start_block * u64::from(QUANTUM), + [&left, &right], + ); + } + + /// Submit the planes `[left, right]` of `generation` to the source `name` at its frame + /// `start_frame`; every frame must be accepted. + fn submit_planes( + &mut self, + name: &str, + generation: u64, + start_frame: u64, + planes: [&[f32]; 2], + ) { + let frames = u32::try_from(planes[0].len()).expect("a chunk's frames"); + let pointers = [planes[0].as_ptr(), planes[1].as_ptr()]; let chunk = SourceChunk { struct_size: SOURCE_CHUNK_SIZE, sample_rate_hz: self.rate, generation, - start_frame: start_block * u64::from(QUANTUM), + start_frame, planes: pointers.as_ptr(), plane_count: 2, - frames: QUANTUM, + frames, end_of_region: 0, reserved0: 0, }; @@ -1462,25 +1505,28 @@ impl CAbi { ) }; assert_eq!(code, C_RESULT_OK, "C ABI source {name}"); - assert_eq!(report.accepted_frames, u64::from(QUANTUM)); + assert_eq!(report.accepted_frames, u64::from(frames)); } /// Render block `block` and return the output's bits. fn render(&mut self, block: u64) -> Vec { - let mut pcm = vec![f32::NAN; QUANTUM as usize * 2]; + self.render_frames(block * u64::from(QUANTUM), QUANTUM) + } + + /// Render the `frames` samples from render sample `at` and return the output's bits. + fn render_frames(&mut self, at: u64, frames: u32) -> Vec { + let mut pcm = vec![f32::NAN; frames as usize * 2]; let output = PlanarOutput { struct_size: PLANAR_OUTPUT_SIZE, channels: 2, samples: pcm.as_mut_ptr(), sample_capacity: pcm.len() as u64, - frames: QUANTUM, - plane_stride_samples: QUANTUM, + frames, + plane_stride_samples: frames, reserved: [0; 2], }; // SAFETY: This thread is the plan's only renderer; `output` is owned storage. - let code = unsafe { - capi::miso_engine_v1_render_f32_planar(self.plan, block * u64::from(QUANTUM), &output) - }; + let code = unsafe { capi::miso_engine_v1_render_f32_planar(self.plan, at, &output) }; assert_eq!(code, C_RESULT_OK, "C ABI render"); bits(&pcm) } @@ -2965,6 +3011,314 @@ fn a_reposition_swap_while_a_level_move_ramps_reports_the_fallback_alike_on_both } } +/// #1651 gate 8 (U1 fold 1, MINOR-3): in a mute-only session (a stored mute curve on `t1`, no +/// fader entry), a rebuild that changes the mute curve while a hand fader move with its own 4,000 +/// samples still ramps completes exactly on both hosts: B17-R14's transition-fallback window is +/// the fader row's alone, because a mute cell's adoption lands a `SetMute`, never a level move +/// over `J`. Both hosts report the same watermark, `EXACT`, and no transition fallback, and render +/// the same bits. +/// +/// Test value: red if the shared control plane opens the fallback window for a successor that +/// automates only a mute row (the candidate is flagged `TRANSITION_FALLBACK`), or if either host +/// marks the rebuild differently. +#[test] +fn a_mute_curve_rebuild_while_a_level_move_ramps_completes_exactly_on_both_hosts() { + let curve = |close: u64| SessionEdit::UpsertAutomation { + automation: session::Automation { + id: id("t1-mute-curve"), + target: address( + Owner::Strip(id(T1.id)), + Section::Builtins, + 6, + ParameterChannel::Both, + ), + segments: vec![ + session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: 0, + end_sample: close, + start_value: 0.0, + end_value: 0.0, + unit: session::ParameterUnit::Linear, + }, + session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: close, + end_sample: 1_000_000, + start_value: 1.0, + end_value: 1.0, + unit: session::ParameterUnit::Linear, + }, + ], + 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 { + let model = model_at(rate); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + 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: one verdict"); + assert_eq!(b_reply, c_reply, "{rate} Hz: one reply"); + b + }; + let rebuilt = |verdict: Verdict| { + assert!( + matches!( + verdict, + Verdict::Applied { + path: EditPath::Rebuild, + .. + } + ), + "{rate} Hz: a mute curve edit rebuilds: {verdict:?}" + ); + }; + rebuilt(both(&[curve(20_000)], &mut browser, &mut c_abi)); + same_blocks(&mut browser, &mut c_abi, 0..4, "the mute-only session"); + both(&hand, &mut browser, &mut c_abi); + same_blocks(&mut browser, &mut c_abi, 4..7, "the hand move"); + rebuilt(both(&[curve(30_000)], &mut browser, &mut c_abi)); + same_blocks(&mut browser, &mut c_abi, 7..10, "the mute curve rebuild"); + let watermark = c_abi.watermark(); + assert_eq!( + status_watermark(&browser), + watermark, + "{rate} Hz: the browser status carries the C ABI's watermark" + ); + assert_eq!( + watermark.2, + capi::OUTCOME_EXACT, + "{rate} Hz: the mute-only rebuild completes exactly" + ); + assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); + assert_eq!( + ( + browser.host.watermark().transition_fallback_count, + c_abi.transition_fallback_count() + ), + (0, 0), + "{rate} Hz: no transition fallback on either host" + ); + } +} + +/// #1651 gates 1 and 3 across hosts (U1 fold 1, MINOR-4): the strip's stored mute under +/// composition renders the same bits on the browser and the C ABI. `t1`'s mute curve closes at +/// 2,000 and opens at 3,500 on both lanes, and VCA `m` holds `t1`. Each of solo on `t2`, a VCA +/// mute of `t1` and a hand mute of `t1` is set at block 2 (the curve open), then released while +/// the curve is closed, released while it is open, or kept; after the curve's opening a plain +/// session seek to timeline 2,600 lands inside the muted span and the curve opens again 900 +/// samples later. Every transaction gets the same verdict and reply bytes on both hosts and +/// every block the same bits, at quanta 128 and 37 (a quantum that puts the steps, the terms and +/// the seek off every grid and block boundary). The curve is heard: `t1`'s muted span differs from +/// a twin with no curve. +/// +/// Test value: red if either host composes `curve || terms` differently (the browser's solo +/// overlay or the C ABI's VCA term), lands a step, a term or the seek's exact set at another +/// sample or over another ramp, or answers a term edit differently. +#[cfg(feature = "test-support")] +#[test] +fn a_composed_stored_mute_renders_alike_on_both_hosts() { + let curve = || session::Automation { + id: id("t1-mute-curve"), + target: address( + Owner::Strip(id(T1.id)), + Section::Builtins, + MUTE, + ParameterChannel::Both, + ), + segments: [ + (0, 2_000, 0.0), + (2_000, 3_500, 1.0), + (3_500, 1_000_000, 0.0), + ] + .map( + |(start_sample, end_sample, value)| session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample, + end_sample, + start_value: value, + end_value: value, + unit: session::ParameterUnit::Linear, + }, + ) + .to_vec(), + offset: session::AutomationOffset::default(), + }; + const VCA_M: VcaRef = VcaRef { id: "m" }; + let rate = 48_000; + for quantum in [128_u32, 37] { + let q = u64::from(quantum); + let mut model = model_at(rate); + model.quantum_frames = quantum; + model.vcas.push(Vca { + id: id(VCA_M.id), + fader: DualMonoFader { + left_db: 0.0, + right_db: 0.0, + left_mute: false, + right_mute: false, + }, + members: vec![id(T1.id)], + }); + // The send does not follow `t1`'s mute: a strip mute that moves a following route's + // gate is a rebuild until #1653, which a solo (monitor) write cannot take. + for route in &mut model.routes { + route.follows_mute = false; + } + let plain = model.clone(); + model.automation.push(curve()); + // The block that holds a sample, and the blocks of the script. + let block_of = |sample: u64| sample.div_ceil(q); + let (set_at, seek_at) = (2, block_of(4_200)); + let blocks = seek_at + block_of(1_900); + let terms = [ + ( + "solo", + Edit::Solo { + strip: T2, + on: true, + }, + Edit::Solo { + strip: T2, + on: false, + }, + ), + ( + "vca", + Edit::VcaMute { + vca: VCA_M, + lanes: Lanes::Both, + muted: true, + }, + Edit::VcaMute { + vca: VCA_M, + lanes: Lanes::Both, + muted: false, + }, + ), + ( + "hand", + Edit::Mute { + strip: T1, + lanes: Lanes::Both, + muted: true, + }, + Edit::Mute { + strip: T1, + lanes: Lanes::Both, + muted: false, + }, + ), + ]; + for (term, on, off) in terms { + for release in [Some(block_of(2_600)), Some(block_of(1_000)), None] { + let label = format!("quantum {quantum}, {term}, released at block {release:?}"); + let document = canonical_session_json(&model).expect("canonical session"); + let mut browser = Rig::boot_at(document.as_bytes(), rate, quantum); + let mut c_abi = CAbi::with_limits( + &model, + &CompileLimits { + source_ring_frames: quantum * 2, + ..limits() + }, + ); + let plain_document = canonical_session_json(&plain).expect("canonical session"); + let mut twin = Rig::boot_at(plain_document.as_bytes(), rate, quantum); + let (mut generation, mut timeline) = (1, 0); + let mut heard = false; + for block in 0..blocks { + let edit = if block == set_at { + Some(on) + } else if Some(block) == release { + Some(off) + } else { + None + }; + if let Some(edit) = edit { + let edits = CAbi::edits(edit, None); + let (b, b_reply) = browser.transaction_reply(&edits); + let (c, c_reply) = c_abi.transaction(&edits); + assert_eq!(b, c, "{label}: one verdict at block {block}"); + assert_eq!(b_reply, c_reply, "{label}: one reply at block {block}"); + assert!( + matches!(b, Verdict::Applied { .. }), + "{label}: the term at block {block} commits: {b:?}" + ); + } + if block == seek_at { + generation += 1; + timeline = 2_600; + assert_eq!( + browser.host.session_seek(None, generation, timeline, None), + host_web::RESULT_OK + ); + assert_eq!( + twin.host.session_seek(None, generation, timeline, None), + host_web::RESULT_OK + ); + c_abi.session_seek(generation, timeline, None); + } + let mut outputs = Vec::new(); + for leg in [&mut browser, &mut twin] { + for (feed, strip) in [T1, T2, T3].into_iter().enumerate() { + let [left, right] = [0, 1].map(|lane| { + (timeline..timeline + q) + .map(|frame| sample(feed as u64, lane, frame)) + .collect::>() + }); + assert_eq!( + leg.host.submit_source( + format!("src-{}", strip.id).as_bytes(), + generation, + timeline, + rate, + &[&left, &right], + quantum, + false, + ), + host_web::RESULT_OK, + "{label}: browser source {}", + strip.id + ); + if outputs.is_empty() { + c_abi.submit_planes( + &format!("src-{}", strip.id), + generation, + timeline, + [&left, &right], + ); + } + } + assert_eq!(leg.host.render_next(), host_web::RESULT_OK); + outputs.push(bits(leg.host.output_pcm().expect("browser output"))); + } + let c = c_abi.render_frames(block * q, quantum); + assert_eq!(outputs[0], c, "{label}: block {block} bits"); + // Inside the curve's muted span, after its ramp: `t1` is muted there, + // unlike in the twin with no curve and no term. + if (2_600..3_400).contains(&timeline) && outputs[0] != outputs[1] { + heard = true; + } + timeline += q; + } + assert!(heard, "{label}: the curve's muted span is heard"); + } + } + } +} + /// #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 e7938ee839dfdd35bf56e151103cda9b7e9b915d Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 10:36:17 +0000 Subject: [PATCH 20/23] Close the batch 18 review test gaps (tests and docs only) - U2b MINOR-1: a muted following route stays silent from its seed. - U2b MINOR-2: a rebuild that hand-mutes the source ramps the send over the edit's own length (the join's before terms). - U2b NIT-2: the realtime binary toggles follows_mute live. - Fold-2 NIT-1: a fader ride rebuild inside J after a flagged seven->six adoption reports EXACT (the narrowed landing window). - Fold-2 NIT-2: the refused adopting render allocates nothing. - Fold-2 NIT-3 and U2b NIT-1: doc wording of the seven->six rule, and the +96 B of capi_retained (a 16 B follow-routes box in six provider epochs) recorded in #1653. Co-Authored-By: Claude Opus 5.5 --- ...mated-mute-on-following-sends-in-render.md | 15 ++ ...sends-route-cell-from-the-shared-commit.md | 34 +++++ crates/capi/src/runtime/live_tests.rs | 69 ++++++++- crates/control-plane/src/control.rs | 13 +- .../tests/follow_automation_realtime.rs | 66 ++++++--- crates/host-core/src/prepare.rs | 12 +- crates/host-core/tests/route_mute.rs | 135 +++++++++++++++++- 7 files changed, 306 insertions(+), 38 deletions(-) diff --git a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md index f6fbbdfea..177b444fe 100644 --- a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md +++ b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md @@ -791,3 +791,18 @@ the exact form is #1713 (phase 2); MINOR-B must land. (the verifier's `MAJ1_into_only`) -> MINOR-C red. The `*curve = false` line removed (the verifier's `unautomated_curve_kept`) -> NIT-D red. `adopt_predecessor` skips the predecessor's pending comparisons -> NIT-E red ("the send reopens from the block C first renders"). + +## Decision record: batch 18 cleanup (fold 2 NITs) + +Test and doc only. NIT-1: `crates/capi/src/runtime/live_tests.rs` +`a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback` gains a second rebuild +(`UpsertAutomation` of a fader ride on `eq0`) sent in the block after the flagged seven -> six +rebuild's adoption, inside `J`; it completes `OUTCOME_EXACT` with the fallback count still 1. Red +when `landing` in `control.rs` is opened by `follow_into_six_word` too (the watermark reads +`(2, 2)`). NIT-2: `route_mute.rs` `a_successor_of_a_plan_that_never_rendered_takes_its_pending_comparison` +measures the refused, adopting `render_contiguous` call with the thread allocator counters and the +render audit (zero); red when a throwaway `Vec::with_capacity` is added inside the window. NIT-3: +the docs of `PlanStateInventory::carries_a_following_route_into_a_six_word_route` (`prepare.rs`) +and the B17-R14/MAJOR-A comment in `control.rs` state the rule (a route whose source strip automates a +mute lane in the committed plan and none in the successor, so the successor gives it the +six-word cell), not the one example; `follows_mute` does not decide the cell. diff --git a/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md b/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md index dcc843097..322f2e202 100644 --- a/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md +++ b/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md @@ -261,3 +261,37 @@ no placeholder remains. No render code, nothing under `crates/engine/src/realtim committed structure (automation is structure, so a value commit cannot change it, and the newest epoch is prepared from that structure), so the refusal is unreachable through the public paths; it stays as D5's defence. + +## Decision record: batch 18 cleanup + +Test and doc only (U2b review PASS; U2a fold 2 verification PASS). No render, control or +preparation logic changed. + +- **MINOR-1 (the seeded `mute` bit).** `crates/host-core/tests/route_mute.rs` + `a_following_send_leaks_nothing_of_a_stored_mute` gains the case `model(128, PreFader, false, + true)` with `routes[1].mute = true` and a `mute_entry(0.0, &[(3_000, 1.0)])`, `Live::None`, at + Simd8, Simd4 and the scalar oracle: the send plane is exact `+0.0` while the strip is heard. + Red with `mute: false` in the seeded `RouteFollowFlags` (`prepare.rs`): "muted route Simd8: a + muted following send leaks". +- **MINOR-2 (the join's `before` terms).** New `route_mute.rs` + `a_rebuild_that_hand_mutes_the_source_ramps_the_send_over_the_edits_length` (all three widths; + the strip's reach of `+0.0` asserted on the bank widths only, as the scalar oracle restarts its + strip). A rebuild from an open `t` with a held-open mute entry to a hand-muted `t`, with the + transaction's `(Strip t, Mute)` edit ramp at 200 (not `M` = 480): the send reaches exact `+0.0` + at the swap block plus 199 or 200. Red with `[m[1], m[1]]` for the join's `terms` + (`join_routes`): the send reaches `+0.0` at 479. The helper `follow::live_swap_ramped` takes the + `EditRamps`; `live_swap` forwards with none. +- **NIT-1 (capi_retained +96 B).** Read from the code, not re-measured: `StripLanes` gained + `follow_routes: Box<[GraphFollowRouteControlProducer]>`, a 16-byte pointer and length held + inline in every `ProviderEpoch`. The control row charges the layout of `PENDING_PROVIDERS` (2) + + `RETIRED_PROVIDERS` (3) parked epochs, and the running epoch is inline in the charged + `SessionState`: six epochs x 16 B = 96 B (168,650 on `main`, 168,746 at head). An empty box + allocates nothing. `crates/capi/tests/resource_lifecycle.rs` explains no row by number, so its + doc is unchanged. +- **NIT-2 (realtime gate).** `crates/control-plane/tests/follow_automation_realtime.rs` + `a_following_send_of_a_stored_mute_renders_allocation_free` now commits the send's + `follows_mute` off at block 9 and on at block 10 (`SetControl` on the route's row 7, each + asserted `EditPath::Live`), inside the muted span, with every render block still allocation- + and free-free; the silence check moved from block 13 to block 18 so the toggles' ramps end + first. Red with the "on" write dropped (the send stays open: "the send follows the curve's + mute"). diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index bc0a16421..b4df19aa9 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -11078,11 +11078,15 @@ fn a_following_send_leaks_nothing_of_a_stored_mute() { /// `OUTCOME_TRANSITION_FALLBACK`, count 1. The removal is sent at block 8 (sample 1,024), while the /// send's columns ramp toward the mute the curve stepped on at 1,000 (480 samples). A rebuild that /// keeps the entry (the following route stays following, seven -> seven) and a rebuild of a -/// session with no following route (no mute entry) complete as `OUTCOME_EXACT`, count 0. +/// session with no following route (no mute entry) complete as `OUTCOME_EXACT`, count 0. A second +/// rebuild that automates `eq0`'s fader in the block after the first one's adoption (inside `J`) +/// completes `OUTCOME_EXACT` too, count still 1: only a level fallback opens the level-move window. /// /// Test value: red if the control plane does not mark a seven -> six rebuild (the six-word /// successor's re-timed column move then completes exact), or if it marks a rebuild whose -/// following routes stay following, or one with no following route (the flag must stay narrow). +/// following routes stay following, or one with no following route (the flag must stay narrow), +/// or if the flagged rebuild opens the level-move window as a level fallback does (the fader +/// rebuild inside its landing then reports the fallback: `landing` read the whole outcome). #[test] fn a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback() { let remove = SessionEdit::RemoveAutomation { @@ -11150,6 +11154,67 @@ fn a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback() { "{label}: the rebuild's revision, outcome flags and transition-fallback count" ); } + // Fold-2 NIT-1: the level-move window opens for a level fallback only. The seven -> six + // rebuild above is flagged but moves no level, so a second rebuild that automates a fader + // in the block after its adoption (inside `J` = 240 samples of it) finds no level move in + // flight and completes exact; the count stays 1. + let document = follow_document(true, false); + let mut rig = Rig::new(&document); + for _ in 0..8 { + rig.step(); + } + let bytes = rig.transaction(&[remove]); + let (result, response) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK); + assert_eq!(edit_path_of(&response), protocol::EditPath::Rebuild); + let first = rig.summary().0; + rig.step(); + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + assert_eq!( + (watermark.revision, watermark.first_sample), + (first, 8 * rig.quantum as u64), + "the flagged successor is adopted at the next block" + ); + let ride = session::Automation { + id: StableId::parse("eq0-ride").expect("automation ID"), + target: session::ControlAddress { + owner: session::Owner::Strip(StableId::parse("eq0").expect("strip ID")), + section: session::Section::Builtins, + // The fader row (builtin parameter 5). + control: 5, + 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(), + }; + let bytes = rig.transaction(&[SessionEdit::UpsertAutomation { automation: ride }]); + 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; + for _ in 0..4 { + rig.step(); + } + assert_eq!(rig.summary().3, 0, "the second successor is adopted"); + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + assert_eq!( + ( + watermark.revision, + watermark.outcome_flags, + watermark.transition_fallback_count + ), + (revision, crate::OUTCOME_EXACT, 1), + "a fader rebuild inside a flagged follow rebuild's landing finds no level move" + ); } /// #1653: sends `edits` on `rig`, which must answer `live`, and renders `blocks` blocks from the diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index dc4f405ca..f7cd40464 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -2630,12 +2630,13 @@ where // that automates only a mute row completes `Exact`. // // #1652 U2a fold 2 (MAJOR-A; #1713 deletes this): a successor whose send join carries a - // following route into a six-word route (the rebuild removes a strip's last mute entry - // while a following send of it exists) may re-time a carried column's move, which render - // cannot report: one ramp of the six-word route takes the session mute length for a join - // record, and folds two columns' moves into one. Such a rebuild completes as the transition - // fallback, whether or not a column moves at the swap: conservative, it over-reports. It - // is not a level move, so it does not open the level-move window. + // following route into a six-word route (a route whose source strip automates a mute + // lane in the committed plan and automates none in the successor: the rebuild removes + // that strip's last mute entry, or retargets it) may re-time a carried column's move, which + // render cannot report: one ramp of the six-word route takes the session mute length for + // a join record, and folds two columns' moves into one. Such a rebuild completes as the + // transition fallback, whether or not a column moves at the swap: conservative, it + // over-reports. It is not a level move, so it does not open the level-move window. let (outcome, rebuild_level) = match &kind { Rebuild::Transaction(prepared) => { let session = prepared.get().prospective_session(); diff --git a/crates/control-plane/tests/follow_automation_realtime.rs b/crates/control-plane/tests/follow_automation_realtime.rs index 3a52f76df..5ab1e0ed0 100644 --- a/crates/control-plane/tests/follow_automation_realtime.rs +++ b/crates/control-plane/tests/follow_automation_realtime.rs @@ -5,8 +5,9 @@ //! `SessionState::command`, the code the browser Worker and the C ABI both run) and host-core's //! render session (`PlanState::render`): track `eq0` sends `pre_fader` with `follows_mute` into bus //! `b`, and a mute entry on `both` steps `eq0` muted at 1,000 and open again at 2,500 (not grid -//! samples). A live move of `b`'s fader (a value the following route does not hold) commits in -//! the middle of the muted span. Every render call after the warm-up allocates and frees nothing, +//! samples). A live toggle of the send's `follows_mute` (off and back on, one block each) and a live +//! move of `b`'s fader (a value the following route does not hold) commit in the middle of the +//! muted span. Every render call after the warm-up allocates and frees nothing, //! measured with the audited allocator's thread-scoped counters: the blocks that cross the curve's //! events, the follow's ramps and the bus move. @@ -250,16 +251,34 @@ impl Rig { /// Commit bus `b`'s fader at `db` on both lanes live, with the session's length. fn move_bus(&mut self, db: f32) { + let address = ControlAddress { + owner: Owner::Strip(id("b")), + section: Section::Builtins, + control: 5, + lane: ParameterChannel::Both, + }; + self.commit_live(address, db); + } + + /// Commit the send's `follows_mute` (route row 7) at `on` live, with the session's length + /// (#1653: a seven-word cell's follow flag is a live write). + fn follow(&mut self, on: bool) { + let address = ControlAddress { + owner: Owner::Route(id("eq0-b")), + section: Section::Own, + control: 7, + lane: ParameterChannel::Both, + }; + self.commit_live(address, if on { 1.0 } else { 0.0 }); + } + + /// Commit control `address` at `value` as one live transaction. + fn commit_live(&mut self, address: ControlAddress, value: f32) { self.request += 1; let edits = [SessionEdit::SetControl { expected_effect: None, - address: ControlAddress { - owner: Owner::Strip(id("b")), - section: Section::Builtins, - control: 5, - lane: ParameterChannel::Both, - }, - value: db, + address, + value, ramp_samples: None, }]; let codec = ProtocolCodec::default(); @@ -278,7 +297,7 @@ impl Rig { let length = self .session .command(&bytes, 65_536) - .expect("the move commits"); + .expect("the control commits"); let mut fields = [0_u16; 64]; let path = match codec .decode_typed_response( @@ -292,11 +311,11 @@ impl Rig { .. } => applied.edit_path, DecodedTypedResponseFrame::NonOk { payload, .. } => { - panic!("the move is refused: {payload:?}") + panic!("the control is refused: {payload:?}") } DecodedTypedResponseFrame::Success { .. } => panic!("another success payload"), }; - assert_eq!(path, EditPath::Live, "the bus move is live"); + assert_eq!(path, EditPath::Live, "the control commit is live"); self.revision += 1; while self .session @@ -308,12 +327,14 @@ impl Rig { } /// Gate 5. Every render block after the warm-up allocates and frees nothing, across the curve's -/// mute at 1,000, a live bus move at 1,792 (inside the muted span) and the unmute at 2,500. Not -/// vacuous: the send (the output's right plane) is silent in a block of the muted span and -/// carries the source again after the unmute. +/// mute at 1,000, the send's `follows_mute` written off at block 9 and back on at block 10 (live +/// writes of the seven-word cell, #1653), a live bus move at 1,792 (inside the muted span) and the +/// unmute at 2,500. Not vacuous: the send (the output's right plane) is silent in a block of the +/// muted span once the toggles' ramps have ended, and carries the source again after the unmute. /// -/// Test value: red if the route op's evaluation of the curve, its cursors, its in-block retarget -/// or the cell read allocates or frees on render. +/// Test value: red if the route op's evaluation of the curve, its cursors, its in-block retarget, +/// the cell read or a live `follows_mute` write allocates or frees on render, or if the toggle +/// leaves the send open for the rest of the muted span. #[test] fn a_following_send_of_a_stored_mute_renders_allocation_free() { bench_support::alloc::assert_installed(); @@ -324,8 +345,13 @@ fn a_following_send_of_a_stored_mute_renders_allocation_free() { let mut silent_while_muted = false; let mut open_after = false; while rig.block < 32 { - if rig.block == 14 { - rig.move_bus(-6.0); + match rig.block { + // The follow flag goes off and back on inside the muted span, the second write + // re-timing the first one's open ramp in flight. + 9 => rig.follow(false), + 10 => rig.follow(true), + 14 => rig.move_bus(-6.0), + _ => {} } let block = rig.block; let used = rig.block(); @@ -335,7 +361,7 @@ fn a_following_send_of_a_stored_mute_renders_allocation_free() { "block {block} allocated or freed" ); let right = &rig.last[QUANTUM..]; - if block == 13 { + if block == 18 { silent_while_muted = right.iter().all(|sample| *sample == 0.0); } if block == 30 { diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 892a65dc0..9d5f30b1b 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -719,8 +719,10 @@ impl PreparedHost { impl PlanStateInventory { /// Whether this plan's send join carries a route that follows an automated source mute in /// the plan it was prepared to displace into a route that follows none in this plan (a - /// seven-word route into a six-word one, #1652 D3): the rebuild that removes a strip's last - /// mute entry while a following send of it exists. `false` for a plan prepared without a + /// seven-word route into a six-word one, #1652 D3). The join flags every such carry by the + /// route's kind alone: a carried route whose source strip automates a mute lane in the + /// committed plan and automates none in this one (the rebuild removes the strip's last mute + /// entry, or retargets it to another strip). `false` for a plan prepared without a /// predecessor and for a declared discontinuity, which carries nothing. /// /// **Conservative (#1652 U2a fold 2, MAJOR-A; #1713 removes it).** A six-word successor holds @@ -728,9 +730,9 @@ impl PlanStateInventory { /// heard curve reopens the route over the session mute length `J` instead of the record's own /// length, and two columns' moves fold into one ramp over the longer remaining length. The /// control plane completes such a rebuild as [`engine::realtime::CandidateOutcome::TransitionFallback`] - /// so the watermark never reports it exact. It over-reports: a rebuild that removes a strip's - /// last mute automation while a following send of it exists is flagged whether or not a - /// column is moving at the swap. + /// so the watermark never reports it exact. It over-reports: a rebuild that carries a + /// following route into a six-word one is flagged whether or not a column is moving at the + /// swap. #[must_use] pub fn carries_a_following_route_into_a_six_word_route(&self) -> bool { self.follow_into_six_word diff --git a/crates/host-core/tests/route_mute.rs b/crates/host-core/tests/route_mute.rs index 230cb69df..bec298485 100644 --- a/crates/host-core/tests/route_mute.rs +++ b/crates/host-core/tests/route_mute.rs @@ -1742,6 +1742,26 @@ mod follow { b: Option<(&SessionModel, &SessionModel)>, backend: Backend, pcm: &successor::Feed, + edit: impl FnMut( + usize, + &mut host_core::HostLiveControlHandles, + &mut engine::realtime::PlanRevisionGate, + ), + ) -> ( + Vec, + Option, + ) { + live_swap_ramped(a, b, backend, pcm, &session::EditRamps::new(), edit) + } + + /// [`live_swap`] with the transaction's edit ramps `edit_ramps` given to the successor's + /// base. + pub(super) fn live_swap_ramped( + a: &SessionModel, + b: Option<(&SessionModel, &SessionModel)>, + backend: Backend, + pcm: &successor::Feed, + edit_ramps: &session::EditRamps, mut edit: impl FnMut( usize, &mut host_core::HostLiveControlHandles, @@ -1793,7 +1813,10 @@ mod follow { &next.compiled, &caps, &request, - host_core::SuccessorBase::new(&inventory, &committed, revision), + host_core::SuccessorBase { + edit_ramps, + ..host_core::SuccessorBase::new(&inventory, &committed, revision) + }, backend, ) .unwrap_or_else(|failure| panic!("successor: {failure:?}")); @@ -1859,9 +1882,13 @@ mod follow { /// the unmute ramp it is bit for bit a plan with no mute, at quanta 128 and 100 (the send has no /// compensation delay). Each plan has exactly one following route, and render allocates nothing. /// +/// A muted route that follows its source (`mute = true`, the strip open until a late step) renders +/// exact `+0.0` on the send plane at every width, while the strip is heard (U2b MINOR-1). +/// /// Test value: red if a following send ignores the curve (its pre-fader audio leaks while the -/// strip is muted), if a plan without live controls attaches no lane for it, or if the send does -/// not reopen after the unmute. +/// strip is muted), if a plan without live controls attaches no lane for it, if the send does +/// not reopen after the unmute, or if the seeded flags drop the route's own `mute` (a muted +/// following send leaks). #[cfg(feature = "test-support")] #[test] fn a_following_send_leaks_nothing_of_a_stored_mute() { @@ -1912,6 +1939,24 @@ fn a_following_send_leaks_nothing_of_a_stored_mute() { } } } + // The seeded flags carry the route's own `mute` (U2b MINOR-1): a muted route that follows + // its source, whose strip is open until a late step, renders exact `+0.0` on the send plane. + let mut stored = model(128, SendTap::PreFader, false, true); + stored.routes[1].mute = true; + stored.automation = vec![mute_entry(0.0, &[(3_000, 1.0)])]; + assert_eq!(stored.routes[1].id.as_str(), "t-b"); + for backend in follow::backends() { + let what = format!("muted route {backend:?}"); + let run = run(&stored, backend, Live::None, &pcm, FRAMES); + assert!( + run.out[1].iter().all(|sample| sample.to_bits() == 0), + "{what}: a muted following send leaks" + ); + assert!( + run.out[0].iter().any(|sample| *sample != 0.0), + "{what}: the strip itself is heard" + ); + } } /// #1652 gate 2 (every bank width and the scalar oracle, quanta 128 and 100). Track `t` carries a @@ -2140,10 +2185,12 @@ fn a_rebuild_that_removes_the_mute_entry_reopens_the_send() { /// from `B`'s inventory, which carries `t-b` six -> six and is adopted by the next call. `C` takes /// the state `B`'s first block would have rendered from: the send reopens over the session mute /// length from that block, and from the next boundary it is `C` prepared fresh, bit for bit. -/// Neither swap allocates on render. +/// Neither swap allocates or frees on render: the refused call that adopts `B` is measured with +/// the thread counters and the render audit, as is every block after the first. /// /// Test value: red if adopting a predecessor that never rendered drops the comparisons its own -/// carry left (`C` then takes the adopted mute, and the send stays silent for good). +/// carry left (`C` then takes the adopted mute, and the send stays silent for good), or if the +/// adoption of a displaced plan allocates or frees on the refused call. #[cfg(feature = "test-support")] #[test] fn a_successor_of_a_plan_that_never_rendered_takes_its_pending_comparison() { @@ -2222,12 +2269,27 @@ fn a_successor_of_a_plan_that_never_rendered_takes_its_pending_comparison() { // Render adopts B at the block boundary, then refuses the call: B renders nothing. let mut output = [f32::NAN; QUANTUM * 2]; let wrong = owner.next_absolute_sample() + 1; + audit::reset(); + let mark = bench_alloc::current_thread_counters(); let refused = owner.render_contiguous( RenderIo { output: PlanarBufferMut::try_new(&mut output, 2, QUANTUM, QUANTUM).expect("output"), }, wrong, ); + let delta = bench_alloc::current_thread_delta_since(mark); + let snapshot = audit::snapshot(); + assert_eq!( + ( + delta.allocations, + delta.reallocations, + delta.deallocations, + snapshot.allocations, + snapshot.deallocations + ), + (0, 0, 0, 0, 0), + "{what}: the call that adopts B and refuses allocates or frees nothing" + ); assert!( matches!(refused, Err(RenderError::TimeDiscontinuity { .. })), "{what}: the call is refused after the boundary" @@ -2327,6 +2389,69 @@ fn a_send_edit_and_a_mute_entry_in_one_rebuild_adopt_over_one_length() { } } +/// #1653 U2b MINOR-2 (every bank width and the scalar oracle). A predecessor whose mute entry +/// holds `t` open is rebuilt at block 6 with `t` hand-muted (both lanes) and a transaction that +/// gave the strip's mute row an explicit 200-sample ramp, not the session's `M`. The send follows, +/// so the join's record is built from the source's effective mute in both models: its `before` +/// term is the open strip, its `after` term the muted one, so the record exists and ramps over the +/// gate length, the strip's 200. The send reaches exact `+0.0` 200 samples after the swap block +/// starts (the strip does too, on the bank widths: the scalar oracle restarts its strip at the +/// swap). +/// +/// Test value: red if the join reads the source's mute of the successor for both terms (the +/// records of the two models then agree, no record is written, and the carried send reopens its +/// ramp over `M` instead of the edit's length). +#[cfg(feature = "test-support")] +#[test] +fn a_rebuild_that_hand_mutes_the_source_ramps_the_send_over_the_edits_length() { + use follow::*; + use successor::{QUANTUM, SWAP_BLOCK}; + const RAMP: u32 = 200; + assert_ne!(RAMP, M); + let pcm = successor::Feed::new("t", 19); + let mut a = model(QUANTUM as u32, SendTap::PreFader, false, true); + a.automation = vec![mute_entry(0.0, &[])]; + let b = statically_muted(a.clone()); + let mut ramps = session::EditRamps::new(); + ramps.set( + &session::EditRampTarget::Strip(sid("t")), + session::EditRampRow::Mute, + Some(RAMP), + ); + let quiet = |_: usize, + _: &mut host_core::HostLiveControlHandles, + _: &mut engine::realtime::PlanRevisionGate| {}; + for backend in follow::backends() { + let what = format!("{backend:?}"); + let (swapped, handles) = live_swap_ramped(&a, Some((&b, &a)), backend, &pcm, &ramps, quiet); + assert_eq!( + handles + .expect("the successor's handles") + .follow_route_controls + .len(), + 1, + "{what}" + ); + assert_quiet(&swapped, &what); + let at = SWAP_BLOCK * QUANTUM; + let send = plane(&swapped, 1); + assert!( + send[at..at + 8].iter().all(|sample| *sample != 0.0), + "{what}: the send is open when the swap block starts" + ); + let end = first_zero_from(&send, at).expect("the send mutes"); + assert!( + (at + RAMP as usize - 1..=at + RAMP as usize).contains(&end), + "{what}: the send reaches +0.0 over the edit's {RAMP} samples, at {}", + end - at + ); + if backend != Backend::Scalar { + let strip = first_zero_from(&plane(&swapped, 0), at).expect("the strip mutes"); + assert_eq!(strip, end, "{what}: the strip and the send ramp together"); + } + } +} + /// Coordinator obligation 4: the following route's charge covers what its attach and bind /// retain. Two tracks, `t` (which sends into bus `b`) and `u`, prepared with no live control, so /// no six-word lane is attached: a mute entry on `t` gives `t-b` its seven-word lane, the same From 776227365e64af234cef055b89630c7c99ab39a9 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 10:39:01 +0000 Subject: [PATCH 21/23] Add placeholder TBD52 and widen #1710 to mute entries (batch 18) TBD52: skip the grid walk for step-only automation cells (mute cells and the following route's walk of them; weekly performance pass). #1710: the per-owner index also serves the mute row and the following-route predicate. Co-Authored-By: Claude Opus 5.5 --- ...mation-entries-per-strip-once-per-build.md | 7 +++++ ...rid-walk-for-step-only-automation-cells.md | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 .github/ISSUE_SPECS/TBD52-skip-the-grid-walk-for-step-only-automation-cells.md diff --git a/.github/ISSUE_SPECS/1710-index-automation-entries-per-strip-once-per-build.md b/.github/ISSUE_SPECS/1710-index-automation-entries-per-strip-once-per-build.md index 401aa1ade..daa41bab6 100644 --- a/.github/ISSUE_SPECS/1710-index-automation-entries-per-strip-once-per-build.md +++ b/.github/ISSUE_SPECS/1710-index-automation-entries-per-strip-once-per-build.md @@ -34,3 +34,10 @@ table, not O(strips × entries). The rows, their values and the `automated` flag - #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. + +## Amendment (controls coordinator, batch 18) + +- Mute entries render since #1651 (row 6). The per-owner index also serves the mute row's readers + (`controls::mute_automation`, the listing's `automated` flag on a driven mute row, R18-2) and the + following-route predicate of #1652 (`following_route_lanes`), so the gate's session also carries + one rendered mute entry per strip and a following send per strip. diff --git a/.github/ISSUE_SPECS/TBD52-skip-the-grid-walk-for-step-only-automation-cells.md b/.github/ISSUE_SPECS/TBD52-skip-the-grid-walk-for-step-only-automation-cells.md new file mode 100644 index 000000000..e09bdae55 --- /dev/null +++ b/.github/ISSUE_SPECS/TBD52-skip-the-grid-walk-for-step-only-automation-cells.md @@ -0,0 +1,28 @@ +# Skip the grid walk for step-only automation cells + +Placeholder from batch 18 (#1651, #1652; weekly performance pass, phase 2). Root files it and gives it +a number. Stateless: everything needed is below. + +## Problem + +A stored mute curve is `step` only, values 0 and 1 (#1645 D1), so its cell is constant between its +discontinuities. `controls::MUTE_CELL_RULE` (`crates/controls/src/automation.rs`) still has +`grid_ramp: 64`, so each render block walks the cell's 64-sample grid and finds no event. A following +route (#1652) walks its source's mute cells the same way in its own node time, once per route per +block. The work is bounded and allocation-free, but it is spent on a cell that cannot move between +its jumps. + +## Decisions to make at filing + +- D1. A step-only cell (every segment `step`) skips the grid walk: its events are its jumps and seek + landings only. The rule is chosen at preparation from the cell's segments, not by the row, so a + future row with step-only curves takes it too. +- D2. No rendered bit moves: the fader and mute gates of #1647, #1651 and #1652 stay green, and a + one-time digest of a session with mute automation and following sends is equal at base and head + (PR evidence). +- D3. Measure first in the weekly pass (the console record's cycles for a session with 64 automated + mutes and their following sends). No change unless the measured cost warrants it. + +## Dependencies + +- #1651, #1652 (shipped in batch 18). From a6574beeadead3975f8eb856f57ffed10223b37d Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 11:16:02 +0000 Subject: [PATCH 22/23] Fix the batch 18 doc links and apply the U1 verification leftovers - Documentation job: two public docs in graph linked private items and one host-core doc had a redundant link target. - The MINOR-4 cross-host gate keeps the following send (its follows_mute override, needed only before #1653, is removed) and its test-value sentence names the adapter as the unique catch. - The M > 0 release arm of rehold is marked defensive; the #1651 record notes the resolved solo refusal and the P3 landing rule. Co-Authored-By: Claude Opus 5.5 --- .../1651-render-stored-mute-automation.md | 15 +++++++++++---- crates/builtins-compiler/src/lib.rs | 2 ++ crates/graph/src/lib.rs | 4 ++-- crates/host-core/src/prepare.rs | 2 +- hosts/host-web/tests/control_parity.rs | 12 ++++-------- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md index 3535bb515..8b13821b4 100644 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md @@ -821,8 +821,15 @@ MAJOR-1 may extend the authorized paths to `crates/automation/src/events.rs`. host-web/test-support` (214), `-p control-plane --features test-support` (28). fmt, clippy (automation, builtins-compiler, control-plane, host-core, host-web; all targets and features, `-D warnings`) and the no-feature `cargo check --all-targets` of the same crates and `capi`. -- **Phase-2 placeholder.** A solo on `t2` in a session where `t1` has a mute curve and a send that - follows `t1`'s mute is refused by the browser with `control.internal` (seen while writing - MINOR-4; the solo moves a following route's gate, which is a rebuild until #1653, and a monitor - write cannot take a rebuild). Not investigated further; #1653's scope. +- **Resolved by #1653 (verified on the merge `a63588680`).** A solo on `t2` in a session where `t1` + has a mute curve and a send that follows `t1`'s mute was refused with `control.internal` on lane M + before #1653 landed. On the merged tree it is accepted on both hosts and composes; the MINOR-4 gate + now keeps the following send (its `follows_mute = false` override is removed). +- **U1 fold verification notes (batch 18).** The M > 0 release branch of `rehold` is defensive: a + muted span is at least 64 samples, so a grid event before E always owes a move that re-commits the + cell, and the M = 48 leg cannot turn red (the verifier confirmed the argument). MINOR-4's unique + catch is a divergence in a host adapter's seek or term delivery (both hosts run the one core). When + a hand record and a curve edit are both needed for a change (P3), the landing ramps over M and the + hand ramp is not used, by the rule "a record that changes nothing against the heard curve is left + to the landing". diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index dfe16f6f8..3a7859b34 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -1628,6 +1628,8 @@ impl FaderCellCursor<'_> { obligation.held_through = true; return; } + // The M > 0 arm is defensive: a muted span is at least 64 samples, so a grid event before + // E owes a move whose landing at E re-commits the cell anyway (#1651 U1 fold verification). self.release = match samples { 0 => span_time(self.start, self.step, u64::from(at)), samples => span_time(self.start, self.step, u64::from(at) + u64::from(samples)) diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 903e2e66b..cfad04414 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1597,7 +1597,7 @@ pub struct RouteControlResources { /// The resources of one [`PreparedGraphPlan::attach_route_controls`] call's `producers` and /// `following` producers, and of the bind that consumes their lanes (issue #1220 D8, #1652 D1): an /// upper bound on both the attached and the bound state ([`RouteControlResources`]). A six-word -/// route is charged its [`RouteLane`] cell, its owner ([`LIVE_ROUTE_OWNER_BYTES`]) and its +/// route is charged its `RouteLane` cell, its owner ([`LIVE_ROUTE_OWNER_BYTES`]) and its /// producer; a following route its own seven-word cell, owner ([`LIVE_FOLLOW_ROUTE_OWNER_BYTES`]) /// and producer, so the charge of a plan with no following route is what it was. The /// route-activity table is one per plan, charged once. Saturating; empty for no producers. @@ -2722,7 +2722,7 @@ impl PreparedGraphPlan { /// [`Self::attach_route_controls`] with following routes (#1652 D1, #1652c): every route into /// a submix input for which `following` gives a source gets a seven-word lane reading it - /// ([`follow_route_control_pair`]) and no six-word lane; every other such route gets a + /// (`follow_route_control_pair`) and no six-word lane; every other such route gets a /// six-word lane when `six_word` is set and none otherwise. Both producer lists are in /// canonical route-ID order. `following` is asked once per route into a submix, in that /// order, so a route into an output never follows (decision 13 P11). diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 9d5f30b1b..5f523015a 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -4329,7 +4329,7 @@ pub(crate) fn route_record_between( /// The record a send's cell takes (#1652 D1): the six folded words of #1347, or, for a route a /// plan gives the seven-word cell (a route into a submix whose source strip automates its mute), /// its open transform and flags. A host writes a `Folded` record through the route's -/// [`RouteControlProducer`](crate::RouteControlProducer) and a `Following` one through its +/// [`RouteControlProducer`] and a `Following` one through its /// [`GraphFollowRouteControlProducer`](graph::GraphFollowRouteControlProducer) (#1653). #[derive(Clone, Copy, Debug, PartialEq)] pub enum SendRecord { diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 2b1a3490b..856f1dac1 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -3124,9 +3124,10 @@ fn a_mute_curve_rebuild_while_a_level_move_ramps_completes_exactly_on_both_hosts /// the seek off every grid and block boundary). The curve is heard: `t1`'s muted span differs from /// a twin with no curve. /// -/// Test value: red if either host composes `curve || terms` differently (the browser's solo -/// overlay or the C ABI's VCA term), lands a step, a term or the seek's exact set at another -/// sample or over another ramp, or answers a term edit differently. +/// Test value: red if a host adapter delivers the seek or a term edit differently (both hosts run +/// the one core, so the unique catch is the adapter: a step, a term or the seek's exact set landing +/// at another sample or over another ramp, or a different answer to a term edit). The send follows +/// `t1`'s mute, so the following route's cell is in the comparison too. #[cfg(feature = "test-support")] #[test] fn a_composed_stored_mute_renders_alike_on_both_hosts() { @@ -3172,11 +3173,6 @@ fn a_composed_stored_mute_renders_alike_on_both_hosts() { }, members: vec![id(T1.id)], }); - // The send does not follow `t1`'s mute: a strip mute that moves a following route's - // gate is a rebuild until #1653, which a solo (monitor) write cannot take. - for route in &mut model.routes { - route.follows_mute = false; - } let plain = model.clone(); model.automation.push(curve()); // The block that holds a sample, and the blocks of the script. From 993bed6dbe12b088df52c513cca207a56388e412 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 11:43:26 +0000 Subject: [PATCH 23/23] Number the batch 18 placeholder: #1714 Co-Authored-By: Claude Opus 5.5 --- ... 1714-skip-the-grid-walk-for-step-only-automation-cells.md} | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename .github/ISSUE_SPECS/{TBD52-skip-the-grid-walk-for-step-only-automation-cells.md => 1714-skip-the-grid-walk-for-step-only-automation-cells.md} (89%) diff --git a/.github/ISSUE_SPECS/TBD52-skip-the-grid-walk-for-step-only-automation-cells.md b/.github/ISSUE_SPECS/1714-skip-the-grid-walk-for-step-only-automation-cells.md similarity index 89% rename from .github/ISSUE_SPECS/TBD52-skip-the-grid-walk-for-step-only-automation-cells.md rename to .github/ISSUE_SPECS/1714-skip-the-grid-walk-for-step-only-automation-cells.md index e09bdae55..00e0e5c57 100644 --- a/.github/ISSUE_SPECS/TBD52-skip-the-grid-walk-for-step-only-automation-cells.md +++ b/.github/ISSUE_SPECS/1714-skip-the-grid-walk-for-step-only-automation-cells.md @@ -1,7 +1,6 @@ # Skip the grid walk for step-only automation cells -Placeholder from batch 18 (#1651, #1652; weekly performance pass, phase 2). Root files it and gives it -a number. Stateless: everything needed is below. +Filed by root from batch 18 (#1651, #1652; weekly performance pass, phase 2). Stateless: everything needed is below. ## Problem