From 65ebedc15a0bda66f0eddcd0dca56ad66e839245 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 12:31:50 +0000 Subject: [PATCH 01/36] Check the batch 19 specs against main and remove the closed batch 18 specs Amend #1654, #1655 and #1711 against 323433f6a: fresh anchors, the mapping onto the batch 17-18 code, the rulings each needs, gates at Simd8 and Simd4 on both hosts, and the authorized paths. #1711 records the diagnosis of the moving stage target (the adoption rule compares the curve with fader_gain, not the ramp target) and the owed-discharge gate. Remove the closed specs #1651-#1653; git history keeps them. Co-Authored-By: Claude Opus 5.5 --- .../1651-render-stored-mute-automation.md | 835 ------------------ ...mated-mute-on-following-sends-in-render.md | 808 ----------------- ...sends-route-cell-from-the-shared-commit.md | 297 ------- ...ed-send-gain-mute-and-matrix-automation.md | 137 +++ ...r-stored-vca-offset-and-mute-automation.md | 181 ++++ ...ves-own-length-across-a-reposition-swap.md | 172 ++++ 6 files changed, 490 insertions(+), 1940 deletions(-) delete mode 100644 .github/ISSUE_SPECS/1651-render-stored-mute-automation.md delete mode 100644 .github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md delete mode 100644 .github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md diff --git a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md deleted file mode 100644 index 8b13821b4..000000000 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ /dev/null @@ -1,835 +0,0 @@ -# Render stored mute automation on the strip - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answers A1.5, A2 and A10, 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 R2. **It lands in the same push as #1652 -*Follow an automated mute on following sends in render* and #1653 *Write a following send's route -cell from the shared commit*: #1651 must not reach `main` without them**, or a send that follows an -automated mute leaks. - -## Product outcome - -A producer's stored mute automation (row 6) plays on every host. A strip lane mutes and unmutes at -the exact samples the session names, over the session mute ramp. Solo (the browser's host -overlay) and VCA mutes still compose with the automated mute. A mute automation edit is a carried -rebuild; a static mute edit on an automated lane, and a browser live mute command on it, commit as -`model_only` on both hosts through the one shared rule of #1648. Sends that follow the strip's -mute are #1652 and #1653. - -## Context - -- **The mute composition.** `user_mute || vca_mute || (any_solo && !solo_safe && !soloed)` is - written once, `LiveControlSoloState::effective_mute` (`crates/host-core/src/solo.rs:255-263`). - *Admit browser live edits in the Worker through the committed model* (#1382) D3 composes the solo - term in the shared commit from the browser's host overlay; the C ABI passes no overlay. *Deliver - value-only VCA edits to the running C ABI plan* (#1247) D2 emits strip `Mute` records from the - effective mute. -- **The mute stage.** `FaderRampStage::set_mute` retargets the lane's gain to 0 or to the - remembered `fader_gain` (`crates/builtins/src/lib.rs:2773-2794`); the kernel clears a muted lane - from the frame its ramp settles on (`crates/lane/src/kernels/builtins.rs:159-202`). #1638 D1 - adds `SetMute { muted, samples }` at an offset and `RememberGain`. -- **The cell.** *Hold live values in latest-target cells on both hosts* (#1312) D3 gives each mute - lane a cell of two words (value bits, ramp). -- **Seam.** The fader and mute stage is seam-side (`crates/builtins-compiler/src/lib.rs:758-771`), so - mute automation never declines a mono collapse. -- **The classifier row list and the predicate** are #1648 D1 and D3. - -## Decisions frozen for this slice - -- **D1. Mute cells (id 6).** Only `step` with values 0 or 1 (#1645 D1). A step curve is constant - between its discontinuities, so a mute cell has no grid events, only jumps. - - The stage mute of a strip lane is `curve || terms`, where `terms` is `vca_mute || solo_mute`. - - **The terms word.** For an automated mute lane, this slice gives #1312's mute cell value word - the meaning `terms` (the mute terms the session does not automate); for every other lane the - word keeps #1312's meaning. Preparation seeds it. #1312 is not amended: this slice adds the - meaning for a lane kind that exists only from this slice on. - - At each discontinuity at render sample `r`, and at the first sample of the block after the mute - cell changes, render composes the stage mute. Only a change emits #1638's `SetMute` at that - offset, over the session mute length (#1639's `mute` word) for a curve event and over the - cell's ramp word for a cell change. The caller reports the mute ramp to the cell's fader events - through #1637's `hold_until`. - - At a session seek where the seek reaches the fader node, the stage mute is set exactly (`n = 0`). - - While a lane's mute ramp is in flight or the lane is muted, fader events only remember the gain - (#1638 D2). -- **D2. Edits, one rule on both hosts.** - - The classifier's row list (#1648 D1) gains row 6: a mute automation edit is a carried - rebuild (#1648 D2 carries mute cells by the same address scheme). A static mute edit on an - automated lane gives no record (#1648 D3); a browser live mute edit on it reaches that rule - through the Worker's apply (#1382) and replies `model_only`. This turns red #1648's gate 1 - case "A mute (row 6) entry change gives `Ok` with no records" - (`crates/host-core/tests/live_delta.rs`); this slice rewrites that case in place to expect - `Err(LiveRebuild::Automation)` (AGENTS.md: a change that supersedes a test rewrites it in the - same PR), and gate 4 below holds the new rule. - - For an automated mute lane, the shared commit's mute composition (#1247's VCA mute, #1382 D3's - solo overlay) writes the strip mute cell's `terms` instead of emitting a `Mute` record, and only - when the terms' bits change. On the C ABI the overlay is absent, so `terms` is the VCA mute; - the code is the same on both hosts. -- **D3. The acked-batch question: can an ack ever precede a drop? No.** Every check of a - transaction (and of #1382's overlay) precedes the first cell write; writes cannot fail; render - generates curve events from the plan. - -## Deliverables - -1. D1: mute events on the fader stage (both bank forms and the fused form), and the terms word's - seed. -2. D2: the classifier row and the shared commit's terms write. - -## Authorized paths - -- `crates/builtins-compiler/src/lib.rs` (the fader stage's mute events and the terms read), - `crates/builtins/src/lib.rs` (only if #1638's mute operation needs a change) -- `crates/host-core/src/{prepare.rs,live_delta.rs,solo.rs}`, `crates/host-core/tests/live_delta.rs` -- `crates/control-plane/src/` (the overlay composition's terms write) -- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/src/tests.rs`, - `crates/control-plane/tests/mute_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` - -## Non-goals - -- Sends that follow the mute, route lanes and the route op (#1652 and #1653). -- Options B, C and D of OQ1. - -## Hazards - -- **One push with #1652 and #1653.** Alone, this slice renders an automated mute while a following - send keeps its prepared gate; the send leaks. Root merges the three together. -- **A redundant retarget moves bits** (README "Change only"; `crates/host-core/src/solo.rs:58-67`). - D1 and D2 compare before they write or retarget. - -## Objective gates - -1. **Exact samples** (both hosts: `crates/capi/src/runtime/live_tests.rs` and - `hosts/host-web/src/tests.rs`, new). Track `t`, with no following send, has a mute entry on - `both` with steps 0, 1, 0, at samples that are not grid samples. Each mute ramp starts at its step - sample; from the end of each ramp the output is bit-identical to a plan prepared with the same - static mute, fed the same PCM. At quanta 128 and 100. -2. **Mute and fader compose** (`crates/builtins-compiler` test, new). A fader ride and a mute step on - one lane: fader events inside the mute only remember the gain; after the unmute ramp the lane is - on the ride, bit-identical to the stage driven directly with #1638's operations. -3. **Composition** (browser, new). Solo on another track mutes `t` by solo while its curve is 0; - `t` mutes; unsolo restores the curve's state. A VCA mute of `t` composes the same way, on both - hosts. -4. **Edits** (`crates/host-core/tests/live_delta.rs`, new). A mute entry change gives - `Err(LiveRebuild::Automation)`; a static mute change on an automated lane gives no record; on the - other, non-automated lane it gives its `Mute` record; a VCA mute of `t` gives one terms write and - no `Mute` record for the automated lane. Browser: a live mute edit through the Worker's apply on the automated lane replies - `model_only` and moves no bit. -5. **Realtime.** `crates/control-plane/tests/mute_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: - `allocations == 0 && frees == 0` around every render call after warm-up. - `cargo build --locked --release -p audit -p capi && ./target/release/audit capi` reports all - violation counts 0. -6. **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 capi`, - `cargo test --locked -p host-web --features host-web/test-support`, - `cargo test --locked -p control-plane --features test-support` - - the workspace debug leg (`test-debug-a`) and the DSP leg (`test-debug-b`) in - `.github/workflows/qualification.yml` - - `cargo build --locked --release -p audit && bash scripts/trace-builtins-audit.sh target/release/audit && bash scripts/trace-builtins-graph-audit.sh target/release/audit` - - `bash scripts/check-web-audioworklet.sh`, `bash scripts/test-web-audioworklet.sh` - - `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), - `bash scripts/run-aarch64-tests.sh debug` - - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `cargo fmt --all -- --check` -7. **No rendered bit moves** for a session with no stored automation: `audit capi` gives the same - `pcm_digest` at base and head (PR evidence); the browser legs pass with unchanged digests. - -## Test value - -- Gate 1: red if a mute step snaps to the grid, uses another row's length, or ramps where the static - plan does not. -- Gate 2: red if a fader event restarts the mute ramp, which `set_fader_gain` does today. -- Gate 3: red if solo or a VCA mute overwrites the curve instead of composing with it. -- Gate 4: red if the mute row stays masked, a static edit or a live command fights the curve, or a - VCA mute emits a record the next curve event would contradict. -- Gate 5: red if the mute events or the cell read allocate on render. - -## Dependencies - -Batch R2. Direct dependencies: - -- #1648 *Classify fader automation edits as carried rebuilds* (the row list, the predicate and - the carry; it brings #1646 and, through it, #1382 and #1247). -- #1639 *Hold the automation jump lengths in a plan cell* (the mute ramp length is its `mute` - word). - -#1638's `SetMute` and the cells of *Hold live values in latest-target cells on both hosts* -(#1312) arrive through #1648. #1652 and #1653 depend on this draft and land in the same push. - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **OQ1 = C: a hand mute ORs.** The stage mute of an automated lane is `curve || terms`, and - `terms` now includes the lane's own static mute: `static_mute || any reaching VCA mute || solo - term`, the effective mute of `controls::effective` (C7) without the curve. So a `SET_CONTROL mute` - on an automated lane is a live `terms` write, path `live`, not `model_only`. D2's "A static mute - edit on an automated lane gives no record (#1648 D3)" and the browser sentence are replaced by - this rule; gate 4 tests a hand mute that closes an open curve, and a hand unmute that leaves a - closed curve closed. When the entry is removed, the lane's mute is the static mute, as before. -- **Solo is on both hosts** (C8, #1559): "On the C ABI the overlay is absent" is no longer true; the - code is the same on both hosts. -- **Moved files.** `crates/host-core/src/solo.rs` is deleted; the composition is - `controls::effective` (`crates/controls/src/effective.rs`). Authorized paths change to - `crates/controls/src/effective.rs` (read) and `crates/host-core/src/live_delta.rs`. -- **The jump length** is `LiveRamps::for_row(Mute)` (#1637, amended). - -## Amendment (controls coordinator, batch 10, 2026-10-14) - -- **Automated VCA mutes** (root's F20 ruling) compose with this slice's `curve || terms` in - #1655: the stage mute of a member lane gains `|| vca_curve(τ)` for each reaching VCA whose mute is - automated. This slice's `terms` word keeps the static VCA mutes. - -## Amendment (controls coordinator, batch 13b, root ruling) - -- **A `SetMute` that changes nothing does nothing** (constraint). Today #1638's `SetMute { muted, - samples }` reaches `mute_channel` (`crates/builtins/src/lib.rs`, through `apply_operation`; the - live `set_mute` calls the same function), which always writes `muted`, retargets the lane to `0.0` - (muted) or to the remembered `fader_gain` (unmuted) over `samples`, and sets `mute_ramp` when - `samples` is not 0, whether or not the lane's mute state changed. So a `SetMute` that repeats the - lane's state restarts a fader ramp in flight over the mute length (its shape changes), and its - `mute_ramp` makes `gain_held` true for that window, so the fader events inside it are only - remembered. The rule: a `SetMute` whose `muted` equals the lane's and channel's current mute state - leaves the stage as it was (no retarget, `mute_ramp` and the ramp's remaining frames unchanged). - D1's "only a change emits" still holds; this rule makes the stage itself safe against a repeated - `SetMute`, whatever composed it. The change - is in `crates/builtins/src/lib.rs`, which this slice's authorized paths already allow; its test - goes in `crates/builtins/tests/fader_ramp.rs` (authorized). -- **Gate 2, new case.** On the stage driven directly with #1638's operations: (a) an unmuted lane - with a fader `RetargetGain` in flight takes `SetMute { muted: false, samples: 240 }` at an offset - inside the ramp, then a `RetargetGain` 10 samples later; (b) a lane in a mute ramp toward `0.0` - takes `SetMute { muted: true, samples: 240 }` inside it. Each block is bit-identical to a twin - 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). - -## 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. - -## 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 (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 - 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. - -## 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. - -## 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). - -## 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. - -## 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. - -## 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. (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). - - 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 (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, - 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`). (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, - `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`. - -## 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`. -- **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/.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 deleted file mode 100644 index 177b444fe..000000000 --- a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md +++ /dev/null @@ -1,808 +0,0 @@ -# Follow an automated mute on following sends in render - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answers A1.3 and A6, 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 R2. **It lands in the same push as #1651 -*Render stored mute automation on the strip* and #1653 *Write a following send's route cell from the -shared commit*.** - -## Product outcome - -Every send into a submix with `follows_mute` from a strip whose mute the session automates follows -that automation in render, on every host and whatever the live-control options. The send's gate -changes at the render sample where the send's tap carries the timeline sample the curve names, so -the send mutes exactly the audio the strip mutes, latency included, and a saved session with an -automated mute and pre-fader sends leaks nothing. A route into the output never follows. The edits -that write the send's cell are #1653's. - -## Context - -- **Route lanes.** Preparation attaches one lane per route into a submix only with a control channel - and `lanes.routes` (`crates/host-core/src/prepare.rs:1574-1592`). At block entry the route op - applies its lane's value, ramping from the coefficients it is at - (`crates/graph/src/runtime.rs:892-914`). The route ramp is the indexed law, a pure function of the - frame index (`crates/lane/src/kernels.rs:1073-1095`, `IndexedRamp` `:1097`, - `coefficients_at` `:1153`). A delayed route keeps feeding its compensation line - (`crates/graph/src/runtime.rs:916-920`): the route op runs before the send's compensation delay. -- **The one route derivation.** `graph::gated_route_coefficients(transform, RouteGate { mute, - follow_zeroed })` (`crates/graph/src/lib.rs:777-812`) is a `const fn` that allocates nothing; - `RouteGate` is `:753-775`. `graph_compiler::route_values` gives the open transform - (`crates/graph-compiler/src/ids.rs:309-322`), and `route_coefficients` composes it with the gate - (`:341-355`). -- **Route cells.** *Hold route-lane values in latest-target cells* (#1347) D1: four coefficients, - mute, ramp. -- **Node time** (README A1.3). A point whose signal arrives at `a` on the compensated graph - processes, at render sample `r`, the audio of timeline sample `timeline(r + ΣP - a)`. For a send - tap, `a(tap)` is the arrival of the tap's signal: the floored input arrival of the stage the tap - reads after (#1285 D2), plus that stage's latency (PDC's output time, - `crates/graph-compiler/src/pdc.rs:53-104`). For an input, post-input or insert-send tap behind - inserts of latency `L`, `a(tap) = a(fader) - L`; for a post-fader or post-pan tap, - `a(tap) = a(fader)`. -- **The strip side** is #1651: mute cells, the stage mute `curve || terms`, events at exact - samples over the session mute length. -- **Swaps.** *Carry strip delay lines and live send ramps across a plan swap* (#1284) D2 carries a - live send's ramp by route ID. -- **#1226 D6** leaves the follow of an automated mute to #1058, through the same composition. - -## Decisions frozen for this slice - -- **D1. A route lane for every following route of an automated mute, on every plan.** Preparation - attaches a route lane, on both hosts and whatever the live options, to every route into a submix - whose source strip has an automated mute lane. For such a route, this slice gives the route cell - seven words instead of #1347's folded coefficients: the open transform (`gain`, `ll`, `lr`, `rl`, - `rr`), one flags word (route `mute`, `follows_mute`, `terms[2]`, `automated[2]`), and the ramp. - `terms[lane]` is the source lane's other mute terms: VCA mute and solo mute for an automated - lane; the whole effective mute for a lane that is not automated. `automated` is fixed by the - plan. Preparation seeds the cell. Every other route keeps #1347's layout; #1347 is not amended. -- **D2. The route op follows the curve at the tap's arrival.** - - The route op evaluates its source strip's mute cells itself, with its own cursors over the same - plan-immutable tables, in the node time of its tap, `τ_tap(r) = timeline(r + ΣP - a(tap))`. A - curve event at timeline sample `t` therefore falls at the render sample where the tap carries - `t`. For a post-fader tap that is the strip's own event sample; for a tap behind inserts of - latency `L` it is `L` samples earlier in render time, and after the send's compensation delay - the send's gate change meets the strip's on the same timeline sample at the submix input. - - **Why the tap's arrival, not the fader's.** A1.3 says automation lines up with the audio it acts - on at every node, latency included. With the fader's arrival, a pre-insert send would gate `L` - samples of timeline late, and pass `L` samples of audio that the strip mutes: the leak A6 exists - to prevent. *Let C ABI sends follow their source strip's mute live* (#1226) D5 ramps a live - follow together with the strip in render time, because a live mute has no timeline sample; a - stored curve has one, and the timeline rule governs it. - - At each source mute event at render sample `r`, and at the block entry after its cell changes: - `follow_zeroed[lane] = follows_mute && ((automated[lane] && curve[lane](τ_tap(r))) || terms[lane])`; - the target is `gated_route_coefficients(&transform, RouteGate { mute, follow_zeroed })`. Only a - target whose bits change retargets: a new `IndexedRamp` from `coefficients_at(position)` at - offset `r` over the session mute length (the cell's ramp word for a cell change). A seek sets - the target exactly where it reaches the tap. - - The session mute length of a route ramp is the `mute` word of #1639's plan cell, read from - the same block context as the strip's fader stage reads it, so the strip ramp and the send ramp - have the same length. - - The route op gains an in-block retarget at an offset on #1638's model: mix `[0, o)`, retarget, - mix `[o, q)`. The indexed law makes the split class A. -- **D3. Swaps.** A route lane present in both plans carries by route ID under #1284 D2, whether or - not its source mute is automated in either plan: a lane gained or lost for D1 is not a change of - the route's structure. This slice extends #1284's join for it; #1284 is not amended. -- **D4. The acked-batch question: can an ack ever precede a drop? No.** Render generates curve - events from the plan; nothing is queued. - -## Deliverables - -1. D1: route lanes for following routes, the seven-word cell and its seed. -2. D2: the route op's evaluation at the tap's arrival and the in-block retarget. -3. D3: the carry join. - -## Authorized paths - -- `crates/host-core/src/prepare.rs` (the route lanes, the cell seed and the carry join), - `crates/host-core/tests/route_mute.rs` (exists; new cases) -- `crates/graph-compiler/src/ids.rs` (only to make `route_values`, `:309-322`, public or to expose - a public wrapper for it), `crates/graph-compiler/src/pdc.rs` (only to export `a(tap)`, if #1285's - inventory does not already give it) -- `crates/graph/src/lib.rs`, `crates/graph/src/runtime.rs` (the route lane, its cell and the route - op only; stream A's files, sequenced by root), `crates/lane/src/kernels.rs` (a range entry only, - if the split needs one) -- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/src/tests.rs`, - `crates/control-plane/tests/follow_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) -- `crates/graph/Cargo.toml` (a normal `automation` dependency: the route op runs #1637's - cursors in graph, and graph cannot reach them through builtins-compiler, - `scripts/check-builtins-policy.sh:21`), `Cargo.lock`, `scripts/check-graph-policy.sh` (the - pinned graph dependency list, `:19-22`) and `scripts/test-graph-policy.sh` (its fixture list, - `:7-13`), each gaining `automation` only. This slice is the first to add the edge. - -## Non-goals - -- The edits that write the route cell, a live `follows_mute` toggle and the solo and VCA terms - (#1653). The strip's mute events (#1651). Send automation (OQ2). Routes into the output, - which never follow (decision 13 P11). - -## Hazards - -- **One push with #1651 and #1653.** Root merges the three together. -- **A redundant retarget moves bits** (README "Change only"). D2 compares the whole target. -- **Delayed sends.** A follow-muted delayed send stays active and mixes zeros (#1226 Hazards). Its - comparison point includes its compensation delay. -- **Two readers of one table** are safe only because the tables are immutable after preparation and - each op owns its cursor; gate 2 proves the samples agree. - -## Objective gates - -1. **No leak** (both hosts: `crates/capi/src/runtime/live_tests.rs` and `hosts/host-web/src/tests.rs`, - new). Track `t` sends `pre_fader` with `follows_mute: true` into bus `b`; `t` has a mute entry on - `both` with steps 0, 1, 0, at samples that are not grid samples. From the end of each mute ramp - (plus the send's compensation delay), the output is bit-identical to a plan prepared with the same - static mute, fed the same PCM. With live controls off in the browser too. -2. **Same timeline sample, same length** (`crates/host-core/tests/route_mute.rs`, new cases). With a - latent insert of `L = 64` before the fader and an `input` tap: the send's route ramp starts `L` - render samples before the strip's mute ramp, with the same length, so both act on the same - timeline sample; with a `post_fader` tap both start on the same render sample. At quanta 128 and - 100. -3. **Route split** (`crates/graph` test, new). A route retargeted at offset `o` in `{1, 63, 64, 127}` - (`q = 128`) and in a block of 100 equals the same block split at `o` with the retarget at the - second part's entry, bit for bit. -4. **Carry** (`crates/host-core/tests/route_mute.rs`). A rebuild that adds an automated mute to `t` - keeps the send's ramp state by route ID; the swap block allocates and frees nothing. -5. **Realtime.** `crates/control-plane/tests/follow_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: - `allocations == 0 && frees == 0` around every render call after warm-up. - `cargo build --locked --release -p audit -p capi && ./target/release/audit capi` reports all - violation counts 0. -6. **Commands:** - - `cargo test --locked -p host-core --features host-core/test-support`, - `cargo test --locked -p graph --features test-support`, `cargo test --locked -p capi`, - `cargo test --locked -p host-web --features host-web/test-support` - - `cargo test --locked -p control-plane --features test-support` - - the workspace debug leg (`test-debug-a`) in `.github/workflows/qualification.yml` - - `cargo build --locked --release -p audit && bash scripts/trace-builtins-graph-audit.sh target/release/audit` - - `bash scripts/check-web-audioworklet.sh`, `bash scripts/test-web-audioworklet.sh` - - `bash scripts/check-realtime-policy.sh`, `bash scripts/check-graph-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), `bash scripts/run-aarch64-tests.sh debug` - - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `cargo fmt --all -- --check` -7. **No rendered bit moves** for a session with no stored automation: `audit capi` gives the same - `pcm_digest` at base and head (PR evidence); the browser legs pass with unchanged digests. - -## Test value - -- Gate 1: red if a following send ignores the curve (the leak A6 exists to prevent), or if a plan - without live controls attaches no lane for it. -- Gate 2: red if the route op times its events at the fader's arrival (a pre-insert send would gate - `L` samples late and leak) or at the route's own arrival, or if the two ramps differ in length. -- Gate 3: red if the in-block retarget restarts the indexed ramp from the target or from rest. -- Gate 4: red if gaining automation on the source re-points the send and drops its ramp. -- Gate 5: red if the route op's evaluation or the cell read allocates on render. - -## Dependencies - -Batch R2. Direct dependencies: - -- #1651 *Render stored mute automation on the strip* (same push). -- #1639 *Hold the automation jump lengths in a plan cell* (the route ramp length is its `mute` - word). -- *Carry strip delay lines and live send ramps across a plan swap* (#1284): the join D3 extends. -- *Hold route-lane values in latest-target cells* (#1347): the cell D1 lays out for these routes. - -#1638's split model, #1637's per-cell events and #1285's arrivals arrive through #1651. -#1653 depends on this draft and lands in the same push. - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **Route lanes exist on every plan** (C6, #1557): every route into a submix has a live lane and a - `ROUTE_CELL_WORDS = 6` cell (`crates/graph/src/lib.rs:1004-1047`). D1's "attach a route lane" is - done; D1 keeps the seven-word layout for a route whose source mute is automated. -- **`terms` includes the source's static mute** (OQ1 = C, #1651 amended): for an automated - source lane, `terms = static_mute || VCA mute || solo term`. -- **#1654** (OQ2 = yes) reuses this slice's seven-word cell and in-block route retarget for a - route whose own rows are automated; build them so the transform words can come from curves too. -- **Moved files.** The follow gate is computed by `controls::effective` (C7); route records come - from `route_records` (`crates/host-core/src/live_delta.rs:1416`) and `RouteControlProducer` - (`crates/host-core/src/route_controls.rs:201`); `live_route_state.rs` is gone. -- **Dependencies.** #1284 is open; #1347 has landed. - -## Amendment (controls coordinator, batch 10, 2026-10-14) - -- **Automated VCA mutes** (#1655): a route into a submix whose source lane is reached by a VCA - 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. - -## 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). - -## 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. - *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 - `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. *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. -- **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. - -## 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. *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; 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 - 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. - -## 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`). - -## 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. - -## 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 #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 - 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; #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. -- **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"). - -## 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 deleted file mode 100644 index 322f2e202..000000000 --- a/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md +++ /dev/null @@ -1,297 +0,0 @@ -# Write a following send's route cell from the shared commit - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answers A6 and A10, 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 R2. **It lands in the same push as #1651 -*Render stored mute automation on the strip* and #1652 *Follow an automated mute on following sends -in render*.** - -## Product outcome - -On a send that follows a strip whose mute the session automates, every live edit that changes the -send's gate reaches it, on both hosts, through the shared commit: a route gain, matrix or mute edit, -a live `follows_mute` toggle, and a change of the source strip's static mute, VCA mute or solo. Each -writes the route's seven-word cell (#1652 D1) and gives no folded follow record, so render -composes the gate with the curve at the next event, and nothing a live edit writes can contradict -the curve. Solo and VCA mutes compose with the curve for the send as for the strip. - -## Context - -- **The C ABI's route records.** *Deliver value-only send edits to the running C ABI plan* (#1225) - D3 emits route records from `route_target(model, route)`; *Let C ABI sends follow their source - strip's mute live* (#1226) D2-D3 makes `follows_mute` live and builds follow records from each - strip's effective mute; #1226 D7 adds `LiveRouteState::set_follows_mute`. #1226 D6 leaves the - follow of an automated mute to #1058. -- **The browser.** *Make a send's follows_mute live in the browser* (#1342) D1-D2: the browser - receives the edit only as a transaction through the Worker's committed model, and its follow - records come from the shared commit. *Admit browser live edits in the Worker through the committed - model* (#1382) D3 composes the solo overlay and the route mirror in the shared commit. -- **The mirror.** `LiveRouteMuteFollow::delta` (`crates/host-core/src/live_route_state.rs:232-257`) - yields only changed routes; the mirror holds `follows_mute` and `source_lane_muted` (`:46-53`). -- **The cell.** #1652 D1: for a route into a submix whose source strip has an automated mute - lane, the cell holds the open transform, a flags word (route `mute`, `follows_mute`, `terms[2]`, - `automated[2]`) and the ramp. - -## Decisions frozen for this slice - -- **D1. Route edits write the cell.** For a route of #1652 D1, the shared classifier emits one - route-cell write instead of the route records that #1225 and #1226 emit for that route (#1226 adds no record type - of its own, #1226 D3). It carries the - open transform from `route_values` (`crates/graph-compiler/src/ids.rs:309-322`), the route - `mute`, `follows_mute` and `terms`, and the ramp from #1226 D5's rule. It writes only when a word's - bits change. The path is `live`. -- **D2. Terms.** `terms[lane]` is, for an automated source lane, `vca_mute || solo_mute`; for a lane - that is not automated, its whole effective mute (#1247 D2's effective mute, with #1382 D3's solo - term). A change of any of them writes the cell. On the C ABI the overlay is absent, so the solo - term is false; the code is the same on both hosts. -- **D3. `follows_mute`.** A toggle writes the cell and, through #1226 D7's setter, the mirror, in the - same shadow as #1382 D3 requires, so a later solo change composes with the new flag. -- **D4. No second path.** #1225, #1226, #1342 and #1347 are not amended: they land before this slice, - and this slice adds the branch for routes whose source mute is automated to the code they built. -- **D5. The acked-batch question: can an ack ever precede a drop? No.** Every check of a - transaction and of the overlay precedes the first cell write; writes cannot fail; render - generates curve events from the plan. - -## Deliverables - -1. D1-D3 in the shared classifier and the commit path, with tests. - -## Authorized paths - -- `crates/host-core/src/{live_delta.rs,live_route_state.rs}`, - `crates/host-core/tests/{live_delta.rs,live_routes.rs}` -- `crates/control-plane/src/` (the route-cell write and the overlay composition) -- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/src/tests.rs` - -## Non-goals - -- The route lane, its layout and the route op (#1652). Send automation (OQ2). - -## Hazards - -- **One push with #1651 and #1652.** Root merges the three together. -- **A redundant write moves bits** (README "Change only"). D1 writes only changed words. - -## Objective gates - -1. **`follows_mute` toggle** (both hosts, new). On a playing engine with `t` automated-muted, a - transaction (C ABI) and a browser edit turn `follows_mute` off, then on: each is `live`; after - the route ramp the output equals a fresh plan of the committed model. -2. **Composition** (browser, new). Solo on another track mutes `t` by solo while its curve is 0; the - send follows; unsolo restores the curve's state. A VCA mute of `t` composes the same way on both - hosts. A route into the output never follows. -3. **Edits** (`crates/host-core/tests/live_delta.rs`, new). A `follows_mute` toggle on a #1652 - route gives one route-cell write and no follow record; a static mute change on the source's - non-automated lane gives that lane's `Mute` record and one route-cell write; a route gain edit - gives one route-cell write with the new transform; an edit that leaves every word unchanged gives - nothing. -4. **Commands:** - - `cargo test --locked -p host-core --features host-core/test-support`, - `cargo test --locked -p control-plane --features test-support`, `cargo test --locked -p capi`, - `cargo test --locked -p host-web --features host-web/test-support` - - the workspace debug leg (`test-debug-a`) in `.github/workflows/qualification.yml` - - `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` -5. **No rendered bit moves** for a session with no stored automation: `cargo build --locked - --release -p audit -p capi && ./target/release/audit capi` gives the same `pcm_digest` at base - and head (PR evidence); the browser legs pass with unchanged digests. - -## Test value - -- Gate 1: red if `follows_mute` is still read from the prepared route only, so a live toggle is lost - on an automated route. -- Gate 2: red if solo or a VCA mute overwrites the curve instead of composing with it for the send. -- Gate 3: red if an edit still emits a folded record that the curve's next event would contradict, - or writes an unchanged cell. - -## Dependencies - -Batch R2. Direct dependencies: - -- #1652 *Follow an automated mute on following sends in render* (same push). -- *Make a send's follows_mute live in the browser* (#1342): the browser's `follows_mute` edit. It - brings #1225, #1226 and #1382. - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **The mirror is gone.** `LiveRouteMuteFollow` and `crates/host-core/src/live_route_state.rs` are - deleted; the send gate is `controls::effective`'s (C7), and route records come from - `route_records` (`crates/host-core/src/live_delta.rs:1416`) and `RouteControlProducer` - (`crates/host-core/src/route_controls.rs:201`). D1-D3 apply to that code. -- **`terms`** for an automated source lane is `static_mute || VCA mute || solo term` (OQ1 = C); - solo exists on both hosts (C8). -- **#1342** is superseded by #1562 (its gate 1). #1225, #1226, #1347 have landed. -- 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/`. - -## 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. - -## 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/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 460aaf48b..f4d721610 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -237,3 +237,140 @@ The rule is the one of #1650, for route row 1 `gain_db` on a route into a submix route row 1 binding and its owner), `crates/controls/tests/**`, and the fields #1650 introduces. `docs/SESSION_SCHEMA_V1.md` names the send gain as taking the offset. The batch R2 order stands: this issue follows #1650 and #1651 to #1653. + +## Amendment (controls coordinator, batch 19 spec check, 2026-10-17) + +Checked against `323433f6a`. The filed text and the amendments above stay; where they disagree, +this section wins. #1651, #1652 and #1653 are closed; read their decision records by number. + +**Anchors now.** +- Rows: `crates/controls/src/route.rs:22-28` (IDs), `:31` (`OUTPUT_FOLD`), `:101-218` (`ROWS`). + Row 7 `follows_mute` is `AutomationRule::Excluded` (#1632 landed), so D7 holds as amended. + `automatable`: `crates/controls/src/descriptor.rs:154`. +- Rendered rows and offsets: `takes_offset` `crates/controls/src/automation.rs:51`, + `offset_faults` `:62`, `automation_row_renders` `:400`, `mute_row` `:407`, `fader_row` `:419`, + `automated_cell` `:484`, `rendered_automation_changed` `:527`, `continued_fader_lanes` / + `continued_mute_lanes` `:573`/`:586`. Code `control.automation.offset`: + `crates/controls/src/error.rs:37,74`. Docs: `docs/SESSION_SCHEMA_V1.md:337-341`. +- Owner edits (R17-1): `OwnerEdits::set` `crates/controls/src/owner_edits.rs:84` (fader branch + `:92-97`), `take_fader_automation` `:105`; row read `crates/controls/src/registry.rs:224`, write + `:565-567`; `ModelView::fader_automation` `crates/controls/src/view.rs:111`; + `write_fader_offset` `crates/controls/src/builtin_rows.rs:155`; `mask_fader_offsets` + `crates/controls/src/mask.rs:138`. +- The classifier no longer masks at `live_delta.rs:359`. Step 3 (`rendered_automation_changed`) is + `crates/host-core/src/live_delta.rs:423`, `lower_routes` `:995`, `route_records` `:1517`. +- Records: `route_record_between` `crates/host-core/src/prepare.rs:4288`, `follow_route_record` + `:4371`, `follow_route_record_between` `:4410`; predicate `following_route_lanes` `:5081`; + attach `:2873-2897`; join `join_routes` `:5829`, `resolve_route_carry` `:5919`. +- One program: `prepare_fader_automation`, `crates/builtins-compiler/src/lib.rs:2618-2752` + (fader cells, then mute cells); `strip_mute_cells` `:5808`. +- Graph: `ROUTE_CELL_WORDS` `crates/graph/src/lib.rs:1010`; `FOLLOW_ROUTE_CELL_WORDS` `:1136`; + `RouteFollowFlags` `:1146` (bits 0-5 used, bits 6-31 spare and refused by `from_word`); + `FollowRouteControlRecord` `:1221`; `GraphFollowRouteSource::new` `:1390` (refuses a route with + no automated source lane). Render: `crates/graph/src/runtime.rs` `LiveFollowRoute` `:1287`, + `ColumnRamps` `:1318` (`retarget` `:1374`, `one_ramp` `:1413`), `FollowCursors` `:1442` + (`block` `:1568`), `follow_target`, `follow_walk`, `apply_cell` `:1698`, `begin_block` `:1745`, + `mix` `:1815`, `finish_six_word_carry` `:5733`. Kernel `route_mix_column_ramps_block` + `crates/lane/src/kernels.rs:1820`. +- dB law: `route_transform` `crates/graph-compiler/src/ids.rs:293` (`math::db_to_gain_f32`), + `route_values` `:330`. Ramp rows: `crates/effect-contract/src/control.rs:76,89,101`. +- Commit: pre-write check `crates/control-plane/src/control.rs:3260-3290` (compares the record's + `automated` with `GraphFollowRouteControlProducer::automated()`); #1713 flag `:2540`, `:2659`; + B17-R14 term `automates = fader_row` `:2648-2651`. + +**How the slice maps onto the landed code.** +- **Rows (D3, R17-6).** `automation_row_renders` gains route rows 1-6 on an `Owner::Route`, + `Section::Own` target (an output route's entry is refused at `.target` first, D8). `fader_row` + stays the fader's only predicate, so no route entry reaches the fader cell, the fader offset or + B17-R14's window. `takes_offset` gains route row 1. `offset_faults` also refuses a route row 1 + entry whose `left_db` and `right_db` bits differ (`control.automation.offset`, at + `.offset.right_db`). New `route_automation(model, route) -> [Option<&Automation>; 6]` (rows 1-6) + is the one enumeration that preparation, the classifier and owner edits read. +- **Offset (R17-6).** Mirror the fader: `OwnerEdits::set` takes the route's row 1 entry with the + route; `registry` reads and writes the offset of an automated row 1 (both fields, one value); + `mask_values` copies route row 1 offsets as it copies fader offsets; the list sets `automated` + on rows 1-6 that an entry drives. +- **Program.** The route cells join the plan's one program in `prepare_fader_automation`, after + the mute cells, in `model.routes` order: rows 1 and 3-6 with `FADER_CELL_RULE` (grid 64, `End`), + row 2 with `MUTE_CELL_RULE` (step). New accessor `route_cells(route_id)`. The function now also + runs for a session whose only rendered entries are route entries. +- **Seven-word predicate (D2).** `following_route_lanes` becomes the predicate of "the plan gives + this route the seven-word cell": source mute automated, or any route row 1-6 automated. It returns + the plan's full automated set. `GraphFollowRouteSource::new` accepts a route with no source mute + cell when it has a route cell. +- **Flags word.** Bits 0-5 keep their meaning. New bits, fixed by the plan like bits 4-5: + bit 6 `automated_gain`, bit 7 `automated_mute` (the route's own row 2), bits 8-11 + `automated_matrix[ll, lr, rl, rr]`. `DEFINED` becomes `(1 << 12) - 1`. When bit 6 is set, word 0 + holds the hand offset in dB (`f32` bits), not a linear gain. `FollowRouteControlRecord::new` + still checks only finiteness, so the offset word fits. #1653's pre-write check compares the whole + automated set (bits 4-11) with the producer's, before any write; it stays D9's defence. +- **Records.** `follow_route_record` takes the route's row 1 offset when bit 6 is set. A + word of an automated matrix row is the static fallback and render ignores it, so a change of + only such words gives no record and commits `model_only` (D4). A change of the offset is a + record over `SendGain` (`live`). +- **The dB law.** Graph does not link `math` (pinned by `scripts/check-graph-policy.sh:21`). + Host-core hands the route op `GraphRouteGainLaw = fn(curve_db: f32, offset_db: f32) -> f32` + (`db_to_gain_f32(clamp(f64(curve) + f64(offset), -144, 24) as f32)`), defined in graph-compiler + next to `route_transform`, which calls it for the static gain. One derivation, no policy change, + in its own realtime-policy region (the `follow_mute_jump` pattern). +- **Route op (D2).** `FollowCursors` becomes one table of up to eight cursors: source mute L/R, + route gain, route mute, matrix ll/lr/rl/rr, each with its row's jump length (mute rule for the + mute cells, `SendGain` for gain, `SendMatrix` for the matrix; the jump-length rule returns all + three). `follow_walk` walks the table in offset order; the values it carries are `f32` (a mute + value is `!= 0`). The composition at each offset: open transform with + `gain = law(curve_gain, offset)` if bit 6, else word 0, each automated matrix entry from its + curve, others from the cell; gate `mute = static_mute || curve_mute` (D5), follow terms as + #1652. One `follow_target`; no second evaluator. A held event changes no value. The + retarget length of a column is the event's own `ramp` (`0` for a `Set`), see ruling R19-A for a + column with a move in flight. `ColumnRamps::retarget` takes one length per column. +- **Record jump (batch 10 amendment, #1649 D3 on the route).** At a record that changes the + offset or a value word, the target takes each automated value from its curve at + `completion_of(End, start, R)` (the `curve_toward` rule), and the route's value cells + `hold_until` that completion, so grid events inside the jump are held. A record that changes + only gate words keeps today's #1652 rule. +- **Carry (D3).** Today the follow cursors do not carry: a carried route places them at + `τ_tap` and compares over the mute `J`. For a gain or matrix cell that re-times a running + ride. Fix, as #1648 D2 / #1651 T4: a route cell whose table both plans hold with the same + segments (`continued_route_rows`) carries its `CellState` and heard value verbatim, by + `(route ID, row)`; another one is repositioned and jumps over its own row's `J` (specified + behaviour, B17-R14 note 1). Source mute cells keep today's placement (a step: exact). A live move + in flight at a Reposition is ruling R19-B. +- **#1713 flag.** `resolve_route_carry` reads the plan's `GraphLiveRoute::following`, so the + widened predicate flags every seven -> six carry, including the removal of a send's last route + entry. Conservative, no new code; #1713's exact form must cover route-row-only routes too. +- **Node time (R17-3), oracle (R17-4), FP (R17-7).** The route op takes `node_span(a(tap))` as + today; it mixes at `FrameLane` on every backend, so the scalar oracle and both bank widths run + the same op. Seeds and records compose on the control thread inside the existing + `CanonicalFpEnv` guards (prepare entry, commit entry); no new guard. + +**Gates (additions).** Gate 1 adds a flat route mute curve at `0` and a flat matrix entry, and +runs in host-core at `Simd8` (avx2), `Simd4` and the scalar oracle, and on both hosts. New gate 7: +a running gain ride across an unrelated rebuild (continued table) is bit for bit the +never-swapped twin (host-core, three widths). New gate 8: an entry on route row 1 with +`left_db != right_db` is refused `control.automation.offset`; a non-zero offset on rows 2-6 is +refused. Gate 3's `+3` move runs through the in-place and the full path with equal reply and +bits on both hosts (R17-6). Gates whose result depends on R19-A or R19-B are written after root +rules. + +**Rulings needed.** R19-A (a value move and a gate move on one folded column ramp) and R19-B (a +live send move in flight at a Reposition, once #1711 deletes B17-R14's window). See the batch 19 +report. + +**Authorized paths after the check.** +- `crates/controls/src/{automation.rs,route.rs,registry.rs,view.rs,owner_edits.rs,mask.rs, + builtin_rows.rs,lib.rs}`, `crates/controls/tests/**`. +- `crates/builtins-compiler/src/lib.rs` (route cells in the one program, `route_cells`; shared + with #1711 and #1655). +- `crates/graph-compiler/src/{ids.rs,lib.rs}` (the gain law and its re-export). +- `crates/graph/src/lib.rs` (flags, source, record, law and jump-rule types), + `crates/graph/src/runtime.rs` (the route op, its cursors and the route carry only). Render code. +- `crates/host-core/src/{prepare.rs,live_delta.rs,route_controls.rs,lib.rs}`, + `crates/host-core/tests/{live_delta.rs,live_routes.rs,route_mute.rs,in_place_commit.rs, + support/edit_sessions.rs}`. +- `crates/control-plane/src/control.rs` (the pre-write check width; the R19-B term if ruled), + `crates/control-plane/tests/send_automation_realtime.rs` (new). +- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, + `hosts/host-web/src/tests.rs`. +- `docs/SESSION_SCHEMA_V1.md`. +- Nothing under `crates/engine/src/realtime/`. No `crates/lane` kernel change unless R19-A picks + option (b). diff --git a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md index 5f3c02a0f..47acabb30 100644 --- a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md +++ b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md @@ -190,3 +190,184 @@ F19), clippy `-D warnings`, `cargo fmt --all -- --check`. carried rebuild. Decision 14, rule 3 entry, for automated VCAs only: the reach change needs new memory (an `own_db` cell word per newly reached lane and the member's curve tables), so it cannot be a live update. Non-automated VCAs keep D15-6's live set. + +## Amendment (controls coordinator, batch 19 spec check, 2026-10-17) + +Checked against `323433f6a`. #1646-#1653 have landed (batches 17 and 18). The filed text stays; +where it disagrees, this section wins. Rulings R19-V1 and R19-V2 below are open; the rest are fixes. + +**Anchors now.** +- VCA rows: `crates/controls/src/vca.rs:14` (`OFFSET_DB`), `ROWS` `:37-73`, `write` `:111`. + `automatable`: `crates/controls/src/descriptor.rs:154`. The classifier masks the table at + `crates/host-core/src/live_delta.rs:404`; step 3 is `:422-425`. +- `controls` (`crates/controls/src/automation.rs`): `takes_offset` `:51`, `automation_row_renders` + `:400`, `mute_row` `:407`, `fader_row` `:419`, `automated_fader_lanes` `:502`, + `rendered_automation_changed` `:527`, `continued_fader_lanes` `:573`, `continued_mute_lanes` + `:586`. `effective_strip` `crates/controls/src/effective.rs:326-366` (`offsets_sum` `:207`). The + hand-offset write: `crates/controls/src/registry.rs:565` (`builtin_rows::write_fader_offset`), + `ModelView::fader_automation` `crates/controls/src/view.rs:111`, + `OwnerEdits::take_fader_automation` `crates/controls/src/owner_edits.rs:105`, + `mask_fader_offsets` `crates/controls/src/mask.rs:138`. +- builtins-compiler (`crates/builtins-compiler/src/lib.rs`): `OFFSETS_CELL_WORDS` `:719`, + `OffsetsLane` `:727`, `AutomatedFaderCell` `:753`, `AutomatedMuteCell` `:839`, + `LaneFaderAutomation` `:858` (`adopt` `:956`, `mute_terms` `:1062`, `read_offsets` `:1128`), + `offsets_jump` `:1285`, `block_jumps` `:1404`, `FaderCellCursor` `:1458`, `FaderEventDriver` + `:1709` (`new` `:1755`), `settle_adoption` `:2146`, `prepare_fader_automation` `:2618`, + `fader_automation_sizes` `:2582`, `strip_mute_cells` `:5808`. +- `automation::CellState` and `CellEvents`: `crates/automation/src/events.rs:91`, `:217`. +- host-core (`crates/host-core/src/prepare.rs`): `MAXIMUM_VCA_REACH_PAIRS` `:144`, the following + route attach `:2873-2897`, `fader_records` `:4493`, `level_records_between` `:4567`, + `StripChange` `:4637`, `join_level_records` `:4928`, `following_route_lanes` `:5081`, + `join_strips` `:5638`, `join_routes` `:5829`. +- graph: `RouteFollowFlags` `crates/graph/src/lib.rs:1146`; `LiveFollowRoute` + `crates/graph/src/runtime.rs:1287`, `FollowCursors` `:1442`, `follow_walk` `:1649`. +- B17-R14's `automates`: `crates/control-plane/src/control.rs:2651`. + +**How the slice maps onto the landed code.** +- **Event lanes.** A member lane is a fader event lane when its fader is automated or an automated + VCA `offset_db` lane reaches it, and a mute event lane when its mute is automated or an automated + VCA `mute` lane reaches it. Two new `controls` predicates give them (one rule for preparation, + the classifier, the join and the route attach). The own-entry readers of #1651 T2 + (`fader_automation`, `model_fader_offsets`, `mask_fader_offsets`, the listing flags) stay + own-entry only: a member's own fader is not automated by a VCA ride. +- **Program.** The VCA cells join the plan's one program after the mute cells, so a session + without VCA automation builds the same program. One cell per automated VCA lane, shared by + every member. `prepare_fader_automation`'s early return already reads `automation_row_renders`. +- **Slot.** An event lane gets a `LaneFaderAutomation` slot and an offsets cell, also when its own + fader is not automated. Its channel's own source is the cell (own automation) or `own_db`. +- **Driver.** The one `FaderEventDriver` (bank widths, fused pair fallback, scalar oracle: R17-4). + Same `a(n)` and span (R17-2, R17-3); no new node-time context. `block_jumps` also resolves + `LiveRamps::for_row(VcaOffset)` and `for_row(VcaMute)` (equal to the fader and mute lengths + today; resolved by row, not assumed). +- **Mute.** `terms` is already `EffectiveStrip::mute` (static mute, every reaching VCA's static + mute, solo term). So D4 needs no new record and no classifier change: the `Mute` record is the + terms write, and `mute_terms` (the drain branch) composes it with the lane's composed curve. +- **Route.** `following_route_lanes` (the one predicate of the attach and of #1653's record) widens + to mute event lanes. `RouteFollowFlags` keeps its six bits; `automated[lane]` now means "the + lane has a mute curve, own or VCA". No spare bit is used (#1654 keeps bits 6 and up). The route + op evaluates the reaching VCA mute cells at `τ_tap` with its own cursors (`FollowCursors`, + `follow_walk`). `route_mix_column_ramps_block` and the per-column ramps (R18-5) do not change. + #1653's pre-write checks hold: plan and record read the same predicate. +- **FP (R17-7).** The `f64` sums of D2 run on render inside the render entry's `CanonicalFpEnv`; + the seed runs inside preparation's guard; the classifier's `S` inside the commit guard. +- **Classifier.** VCA rows 1 and 2 join `automation_row_renders`, so a VCA entry change is + `Err(LiveRebuild::Automation)` at step 3. The reach rule (D5 last bullet, root's ruling) goes in + `controls::rendered_automation_changed`: it also returns `true` when the `(member strip, lane)` + set that an automated VCA lane reaches differs (membership, nesting, an added or removed VCA). + Step 3's call in `live_delta.rs` does not change (docs only). The fast path (`classify_rows`) + takes `SetControl` only, which cannot change a reach, so it needs no change. +- **Hand edits (D5).** `takes_offset` gains VCA `offset_db`. As for the fader (#1650 D3, D4, + R18-2): `registry::write` sends a hand move on an automated VCA lane to the entry's offset; + `ModelView` and `OwnerEdits` read and take the VCA entry for the in-place edit (R17-1); + the VCA row's value is the offset and the list marks it `automated` (field 35); `mask_values` + copies the entry's offset and leaves the automated lane's static offset (its fallback) as + structure. Without these a write and a read of the same row disagree. +- **`S` (D2).** `effective_strip` puts, on a fader event lane: the own hand offset, then the static + offsets of the reaching VCA lanes that are not automated, then the hand offsets of the automated + ones, each group in ascending VCA ID. With no automated VCA lane this is today's sum, bit for bit. +- **Records (D3).** `level_records_between` and `fader_records` take the fader event lanes as + `automated` (no `FaderDb` there). The offsets record of an event lane carries `own_db` from + `StripChange::own_after` (no new `EffectiveStrip` field). + +**Fixes.** +- **D3 and D7 bytes.** `LatestCell` is const-generic. A slot of three words and a slot of four + words are both 32 bytes (two `AtomicU64` set the alignment), so a four-word cell costs nothing + more. Every offsets cell becomes four words (`OFFSETS_CELL_WORDS = 4`; the `own_db` word is + unread on a lane whose fader is automated). One cell shape, one reader. No edit under + `crates/engine/src/realtime/`. `offsets_cell_bytes` stays 120 (x86-64); the gate measures it. +- **D7 terms.** D7 leaves out that an event lane with no own automation also takes a slot + (`lane_slot_bytes`) and an offsets cell. The per-pair term depends on R19-V1. All terms are + bank payload, charged by `fader_automation_sizes()` and checked as ceilings, never literals. +- **D6 carry.** A carried channel continues verbatim when its own table and each reaching automated + VCA table (in ascending VCA ID) are the same set, bit for bit, and `a(n)` did not move; else it + repositions. This is `continued_cells` (bits 0-3) from the widened `continued_fader_lanes` and + `continued_mute_lanes`; graph passes the byte unchanged. No per-pair carry key is needed, because + a repositioned channel places each cursor again. A lost event lane is released by the join, as + today (`join_level_records`, widened to event lanes). +- **Reposition and #1711 (R18-4 withdrawn).** A VCA offset table edit repositions every reached + member channel. Its landing can cut a live move in flight (a member's `own_db` move, a VCA hand + move), as a fader table edit does. So: land #1711 first and use its deferred jump for every + repositioned channel; a VCA case joins its G1 here (gate 7 below). If #1711 does not land, the + B17-R14 flag stays and `automates` (`control.rs:2651`) widens to VCA `offset_db` entries, so + a VCA-only session's table edit is flagged, never `EXACT` when wrong. A VCA mute table edit + lands only a `SetMute` (the stage keeps `fader_gain`, #1651 T4); U1 fold 1's rule must hold + for the composed mute: a terms record at the swap lands over its own ramp word (gate 8). + +**R19-V1 (ruling needed): how a member channel holds and walks its curves.** D1's per-pair state +(one `CellState` per member and VCA lane) cannot hold a correct composition. Each `CellState` keeps +its own ramp in flight and its own `held` rule, but the stage has one ramp. A member grid event +inside a VCA jump retargets the stage over 64 samples, so the VCA step ends in 64 samples, not in +`J` (and the reverse). Also the driver's arrays are fixed (`MAXIMUM_FADER_CELLS` on the render +stack), and `m` has no compiled bound. +- (a) D1 as filed: per-pair `CellState`, events merged by offset. Small. A VCA step is shortened + whenever the member's own ride moves. Not correct. +- (b) One composite cell per member channel: one `CellState` over the composed curve + `vca_compose_db(own(t), S + c_1(t) + … + c_m(t))` (for mute: `own || v_1 || … || v_m`). + Events at the union of the curves' discontinuities and the shared node-time grid; target, held + and lagging rules on the composed value. A new K-curve event source in `crates/automation` + (`events.rs`, render code, not under `crates/engine/src/realtime/`): per pair one discontinuity + cursor and a cell index (8 bytes) in a boxed slice of the slot, allocated at preparation. With + `m = 0` the driver keeps today's `CellEvents` path, so no fader-only or mute-only bit moves. The + route op uses the same source per lane (OR). D8 holds (events at most the sum of the curves'). + About 8 h more than (a). +- (c) Split: this slice renders VCA `mute` (D4, the reach rule, D5's mute bullet, D6 for row 2); + VCA `offset_db` (D2, D3, D5's offset bullet) moves to a phase-2 successor with (b). +- Recommendation: (b). Fall back to (c) if the offset tranche fails its second fix round. + +**R19-V2 (ruling needed): the reach-pair memory.** The host cap is 16,384 (strip, VCA) pairs +(`MAXIMUM_VCA_REACH_PAIRS`), per lane and per row. Under (b) that is at most about 512 KiB of pair +state per plan; under (a) about 3 MiB. Options: (i) charge it as bank payload under the existing +caps (no new cap); (ii) add a host cap on automated pairs. Recommendation: (i) with (b). + +**Gates (fixes; both hosts and both bank widths).** +- Gates 1-3: both hosts in one binary, `hosts/host-web/tests/control_parity.rs`, at quanta 128, + 100 and 37. The same scripts in `crates/host-core/tests/stored_vca_automation.rs` (new) at + `Backend::Simd8` (`cfg(target_feature = "avx2")`) and `Backend::Simd4`, split and fused, live and + between render calls, and on the scalar oracle; bits equal. Gate 1 adds two VCAs on one member + (one automated, one static) and a member with its own ride. Gate 3 adds a member with no own + mute entry (its send becomes seven-word) and a nested VCA. +- Gate 4 reads: a hand fader move on a member with no own automation gives `live`, one offsets + write carrying `own_db` and no `FaderDb`. Add: the VCA row lists the offset as its value and is + marked `automated`; a replace that changes only an automated VCA lane's static offset is + `rebuild`. In `crates/host-core/tests/live_delta.rs` (run with + `--features host-core/test-support,host-core/control-provider`) and both hosts. +- Gate 5: `crates/control-plane/tests/vca_automation_realtime.rs` gains the curve, a hand VCA move, + a hand member move and a VCA mute, each block `allocations == 0 && frees == 0`. +- Gate 7 (new, Reposition): a VCA ride table edit while a member's hand move (and a VCA hand move) + ramps, heard 1, 12 and 26 blocks: with #1711, every block after the swap equals the + uninterrupted engine and the watermark reports `EXACT`; without #1711, the flag. Both hosts, + both widths. +- Gate 8 (new, carry): a VCA mute table edit with a hand VCA mute in the same transaction lands the + hand mute over its own ramp; added, removed and changed VCA entries each rebuild and carry the + member stage; the swap block allocates nothing. Bank widths (the oracle restarts at a swap, + #1651 T4 departure). +- Gate 6 command: `audit capi` `pcm_digest` stays `bffc21e857b5e134`. Add `cargo test --locked -p + automation --all-features`; `cargo build --locked --release -p realtime-policy && + ./target/release/realtime_policy` (the script is deleted). + +**Authorized paths after the check (this list replaces the filed one).** (R) marks render code. +None is under `crates/engine/src/realtime/`, so no root notice is needed. +- (R) `crates/automation/src/{events.rs,lib.rs}`, `crates/automation/tests/events.rs` (only under + R19-V1 (b); a path extension root approves with the ruling) +- (R) `crates/builtins-compiler/src/lib.rs` (VCA cells, event lanes, slot, offsets cell shape, + driver, drain, adopt, export to host-core) +- (R) `crates/graph/src/runtime.rs` (`FollowCursors`, `follow_walk`, the follow route's carry + only), `crates/graph/src/lib.rs` (`GraphFollowRouteSource`'s cells, docs) +- `crates/controls/src/{automation.rs,vca.rs,effective.rs,registry.rs,view.rs,owner_edits.rs,mask.rs,lib.rs}`, + `crates/controls/tests/{automation.rs,mask_and_diff.rs}` +- `crates/host-core/src/prepare.rs` (event-lane predicates, follow attach, level records, strip + and route joins), `crates/host-core/src/live_delta.rs` (docs only) +- `crates/host-core/tests/{vca.rs,live_delta.rs,stored_vca_automation.rs,route_mute.rs}` +- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, + `crates/control-plane/tests/vca_automation_realtime.rs` +- `crates/control-plane/src/control.rs` (only if #1711 does not land: `automates`) +- `docs/SESSION_SCHEMA_V1.md` (`:339-340` the offset rows; the VCA rows render; D2's order), + `docs/REALTIME_MEMORY.md` (the pair term) +- No SDK regeneration: no row class or table changes. + +**Edit order with the batch.** #1711 first (driver adoption, `events.rs`, `control.rs`), then #1654 +(`following_route_lanes`, the route op, `takes_offset` and its test), then this slice. +Program cell order: fader cells, mute cells, #1654's route cells, then the VCA cells (VCA ID, +row, lane), so each earlier session builds its program cell for cell. If #1654 generalizes +`follow_walk` to a table of cursors, this slice adds the VCA mute cursors to that table and +writes no second walk. 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 d5147a73a..c8ae26983 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 @@ -107,3 +107,175 @@ conservative fallback flag of B17-R14 with the exact behaviour and deletes that 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. + +## Amendment (controls coordinator, batch 19 spec check, 2026-10-17) + +Anchors are on `323433f6a`. #1648 is closed; batches 17 and 18 are merged. Every number below was +measured with temporary probes in a detached worktree at `323433f6a` (removed after use). + +### A0. Diagnosis: the stage target does not move; the comparison reads the wrong field (a defect) + +- The stage's ramp target stays at the composed curve at the ramp's end: 0.301812 at every + `remaining` (3872, 2464, 928, 672, 160), VCA and hand moves, both tables. +- `fader_gain` is not the ramp target. Inside a live move's ramp the cell's grid events are held + (`held: true`, `crates/automation/src/events.rs:391-409`). The driver gives them to the stage as + `RememberGain` (`crates/builtins-compiler/src/lib.rs:2026-2043`), and `RememberGain` writes + `fader_gain` with no retarget (`crates/builtins/src/lib.rs:3184`). So `fader_gain` follows the + ride (0.466 heard 1, 0.398 heard 12, 0.335 heard 24, 0.325 heard 26, 0.307 heard 30). +- `bank_hold` puts `fader_gain` in `ChannelHold` (`lib.rs:4032-4041`, field `:1438`), and + `settle_adoption` compares the curve with it (`lib.rs:2196-2198`). On a moving ride it always + differs, so every heard move lands at the swap. +- Fix F0 (smallest): for a free stage (`!held`), `ChannelHold` carries the stage's ramp target + (a new `BuiltinFaderBank` accessor over the kernel's `target_gain`, `builtins/src/lib.rs:3196`, + beside `fader_gain` at `:4639-4649`). A held stage keeps `fader_gain` (the gain an unmute lands + on; correct there). Measured with F0: all 40 cases of the gate setup (VCA and hand, both tables, + heard 1, 12, 24, 26, 30) are bit-equal to the uninterrupted engine over 44 blocks, adopted and + allocation-free. At HEAD: 4.6e-6 (heard 12), 4.8e-4, 3.1e-3, 2.1e-3 (heard 24, 26, 30). +- Rows: the fader stage only. VCA and hand moves both (one offsets-jump path, R17-1), and any + free ramp the cell holds (its own `J` jump or grid ramp). Mute cells are not affected (no gain + comparison). #1655's VCA cells drive the same stage and inherit F0. +- MINOR 4 (`at = end`): with F0, G1 is red if the curve is compared at the block's first sample + (measured: the same 4.8e-4, 3.1e-3, 2.1e-3). G2 as filed cannot be red: on the flat ride + `curve_toward` stops before the step, so `at = start` and `at = end` read the same value. G2 is + replaced (A3). + +### A1. The owed discharge: observable, so keep it and gate it (G4) + +- The line is now `crates/builtins-compiler/src/lib.rs:2104` (`HeldRampObligation::block`, + `:2080-2111`): a free stage at a block entry with no move due. +- `owed` can be true there only when the stage held the gain through the previous block and a + live write freed it at this entry (an exact live unmute). Without the line, the stale `owed` + survives the free blocks and makes a later hold's end `E` land a move over `J` + (`lib.rs:2243`, `moving`) when nothing was remembered in that hold. +- Measured on the C ABI (quantum 128): ride linear 0 to -6 dB over `[0, 1280)`, flat -6 dB to + 2320, then linear. Hand mute (ramp 256) at block 4, exact hand unmute (ramp 0) at block 8, hand + mute (256) at block 12, hand unmute (200) at block 16, so `E` = 2248 with no grid event in the + second hold. Reference: the same rig with only the second hold. With the line, bit-equal from + block 9. Without it, the first difference is block 17 (2.7e-4). + +### A2. D1 against the code (amended) + +- F0 first. An unmoved target then takes no landing: the cell seeds its target and holds the ramp + again (`settle_adoption`, `lib.rs:2174-2202`, already does this when `!differs`). +- A moved target on a free stage with a ramp in flight (and no step at the block's first sample): + no landing at the swap. The cell calls `CellState::defer_jump(at)` with `at` the ramp's first + free sample (`node_time_at(span, remaining)`). The existing deferred path + (`events.rs:456-490`) gives a `Jump` over the block's `J` there, change only. +- One rule change in `crates/automation` (measured as needed): on a restartable cell + (`no_restart` false) grid events before a pending deferral are held. Without it the grid point + 32 samples before the move's end retargets to the new curve (a grid ramp that completes after + the ramp in flight restarts, `events.rs:395-396`), the move is cut short, and change only then + drops the jump. A `no_restart` cell is unchanged. +- The deferral is cleared by `reposition` and a seek landing (both already), and by a new + `CellState::clear_deferred_jump()` that the fader cell calls where the ramp it waits for is + replaced or ends early: the free path of `offsets_jump` (`lib.rs:1346-1352`, a newer live move + keeps its own length), the R17-10 release (`:1920-1926`) and the `release_hold_by` sites + (`:1576`, `:1592`). Reason for the last two: a held grid would freeze the lane after an early + unmute (the #1651 U1 MAJOR-1 defect). +- Kept: the ramped landing at the block's first sample over `J` when no ramp is in flight, when + the stage is held, for D3 (a step at the block's first sample), and for the `Compare` backstop. +- Deleted: `long_landing` and its one-sample landing arm (`lib.rs:1898-1919`, `:1966-2009`) and + `length = length.max(remaining)` (`:2191`). `settle_adoption` no longer returns a length. +- Prototype measured (F0 + `defer_jump` + the held grid): G2's setup (A3) is bit-equal to its + reference at heard 12 (2464 left) and heard 30 (160 left), VCA and hand. At HEAD: 9.4e-3 and + 1.5e-3 from the swap block. With the prototype on, the capi `automated` tests (24), host-core + `stored_fader_automation`, `stored_mute_automation`, `successor_swap`, `session_seek` and the + `automation` and `builtins-compiler` tests stay green. +- No new state: `CellState::deferred` exists (`events.rs:102`, 32 B unchanged). + +### A3. Gates (amended) + +- G1 (unmoved target). The existing gate (`live_tests.rs:9059-9147`) becomes bit-equal (not 1e-3) + for Pending, SameTransaction and heard 1, 12, 24, 26, 30; both tables; VCA and hand. Add: two + table-only rebuilds 5 blocks apart while a 500-sample move ramps (the tranche-5 setup). Every + rebuild's watermark advance reports `OUTCOME_EXACT` and no transition fallback. Simd8 on the + C ABI. Simd4 and Simd8 in `crates/host-core/tests/stored_fader_automation.rs` through a carry + harness with live offsets writes (the pattern of `carry_run`, + `crates/host-core/tests/stored_mute_automation.rs:1129`). The scalar oracle has no plan swap + (`ChannelHold` there, `lib.rs:8234`). Both hosts: the parity test at + `hosts/host-web/tests/control_parity.rs:2933-3012` becomes "a hand move in flight reports + `EXACT` alike on both hosts, same bits". +- G2 (moved target, replaces the flat-ride G2). Base ride flat at -3 dB (`flat()`); move -6 dB + over 4,000 samples from block 4; rebuild to a ride flat at -5 dB to 6,000, then -3 dB; heard 12 + and 30; VCA and hand. Reference: a fresh plan whose table is -3 dB before the move's first free + sample (4,512 here; compute it from the rig), -5 dB to 6,000, then -3 dB, given the same move. + Every block is bit-equal to the reference, allocation-free, adopted, `OUTCOME_EXACT`. Red at + HEAD (above). Case (c): a second live move committed after the swap, before 4,512: bit-equal to + a fresh plan with the new table given both moves, from the second move's last sample on (red if + the deferral is not cleared). Simd8 and Simd4 in host-core as for G1; the C ABI leg; one + both-host parity case. +- G3 as filed. Named: `a_step_at_a_reposition_swap_jumps_over_the_fader_length` + (`live_tests.rs:9186-9289`) and the #1648 carry gates in `automated_carry`. Also the G5 + automation corpus digests (`crates/automation/src/corpus.rs:245`, + `tools/wasm-gates/tests/g5_native_corpus.rs`) do not move. Realtime policy count unchanged or + explained. +- G4 (owed discharge, A1). The A1 setup in `stored_fader_automation.rs` with `acted` on every form + (`FORMS`, `:2092-2100`: Simd8, Simd4, the between-render forms and the scalar oracle), and on + both hosts in `control_parity.rs` (same bits). Test value: red if the discharge line is + deleted. + +### A4. D2 exact deletions + +- `crates/control-plane/src/control.rs`: the `level_moves` field (`:649-651`); + `LevelMoveWindow`, its `may_ramp`, `fold_level_move`, `rebuild_level_ramp`, + `rebuild_moves_a_level` (`:654-735`); the B17-R14 comment paragraph (`:2621-2629`, keep the + #1652 U2a paragraph); `automates`, `in_flight`, `level_fallback`, `moved`, `landing` and the + `rebuild_level` value (`:2640-2672`; the outcome becomes `follow_into_six_word` alone); the fold + after commit (`:2866-2870`); the live commit's `level_ramp` and its fold (`:3519-3539`, + `:3555-3557`). `crates/control-plane/src/compile.rs:1483` (`level_moves: None`). +- Keep `reservation.set_outcome` (`control.rs:2840`) and `ObservedReservation::set_outcome` + (`crates/control-plane/src/plan.rs:199-206`): #1713's six-word fallback uses them. Fix the doc + at `plan.rs:199-200` to name #1713 only. +- The B17-R14 clause: `crates/capi/src/abi.rs:96-97`, `crates/capi/include/miso_engine_v1.h:183-184`, + `docs/C_ABI_V1_QUALIFICATION.md:153-154`, + `hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts:782-784` and its generated copy + `sdk/src/browser/shipped-host.d.ts:782-784` (`scripts/check-sdk-generated.sh`). Keep the #1713 + clause. Doc mentions of the window: `crates/controls/src/automation.rs:397-398`, `:414-415`. +- Tests deleted: `a_reposition_swap_while_a_level_move_ramps_reports_a_fallback` + (`live_tests.rs:9297-9368`), `a_second_reposition_inside_a_stretched_landing_reports_a_fallback` + (`:9370-9414`), and + `a_mute_curve_rebuild_while_a_level_move_ramps_completes_exactly_on_both_hosts` + (`control_parity.rs:3014-3114`, its window is gone). The `outcome` helper + (`live_tests.rs:9290-9295`) stays for G1. + +### A5. #1655 (same batch, same fader stage) + +- #1711 lands first. It removes the window that #1655 would otherwise have to extend for VCA + level moves. +- #1711 assumes: (1) the adoption compares the stage's ramp target (F0), which does not depend + on how the gain is composed; (2) the curve side of the comparison is the lane's composed gain at + `at`. Today that is `FaderEventDriver::gain(value, S)` (`lib.rs:2196`). #1655 replaces it with + its one composition `checked_fader_gain(vca_compose_db(own(at), S + Σ c_k(at)))`; (3) one + deferral per lane channel, so one `Jump` fires at the free sample, composed with every cell at + that node time. #1655 names which cell holds it on an event lane with no own fader cell, and + gives its VCA cells the same grid-hold and clear rules. + +### A6. Mapping onto the batch 17-18 code + +- Fader cells and the one event driver: `FaderEventDriver::new` (`lib.rs:1755`), adoption at + `:1898-1919`. Offsets cells and hand moves (`takes_offset`, R17-1): one `offsets_jump` path + (`:1285`). Jump lengths: `BlockJumps` (`:1404`). Node time (R17-3): `take_block_time` + (`:1044`). +- The scalar oracle (R17-4) shares the driver; no swap reaches it. It is in G4 only. +- R17-7, the classifier rows and carry, `LatestCell<7>`, `route_mix_column_ramps_block`, the + #1653 commit write and mute `curve || terms`: not touched. Mute adoption stays + `AutomatedMuteCell::reposition`. +- `controls::fader_row` and `automation_row_renders`: D2 deletes the only control-plane reader of + `fader_row` for the window (`control.rs:2651`). +- The conservative TransitionFallback: B17-R14's is deleted (D2). #1713's stays. + +### A7. Authorized paths + +- Render (realtime policy regions): `crates/builtins-compiler/src/lib.rs`, + `crates/automation/src/events.rs`, `crates/builtins/src/lib.rs` (one accessor). +- Control and docs: `crates/control-plane/src/control.rs`, `crates/control-plane/src/plan.rs`, + `crates/control-plane/src/compile.rs`, `crates/controls/src/automation.rs` (doc only), + `crates/capi/src/abi.rs`, `crates/capi/include/miso_engine_v1.h`, + `docs/C_ABI_V1_QUALIFICATION.md`, `hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts`, + `sdk/src/browser/shipped-host.d.ts`. +- Tests: `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, + `crates/host-core/tests/stored_fader_automation.rs`, + `crates/host-core/tests/support/successor.rs` (only if the carry harness needs a live-write + hook). +- This spec. +- Nothing under `crates/engine/src/realtime/`. From 0a73fa9156b68d405102445e7a47da59e9acf7ca Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 12:35:14 +0000 Subject: [PATCH 02/36] Record root's batch 19 rulings in the specs (R19-1 to R19-6, two PRs, the #1713 note) Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 21 +++++++++++++++++++ ...r-stored-vca-offset-and-mute-automation.md | 15 +++++++++++++ ...ves-own-length-across-a-reposition-swap.md | 10 +++++++++ ...moves-exactly-into-a-six-word-successor.md | 7 +++++++ 4 files changed, 53 insertions(+) diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index f4d721610..2b6065987 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -374,3 +374,24 @@ report. - `docs/SESSION_SCHEMA_V1.md`. - Nothing under `crates/engine/src/realtime/`. No `crates/lane` kernel change unless R19-A picks option (b). + +## Rulings (root, batch 19, 2026-10-17) + +These override the batch 19 spec-check amendment where they differ. + +- **R19-3 = (d).** A route column uses the fader's deferred-jump rule. While a column jump runs (a gate + change over the mute `J`, a record ramp `R`, or `0` for a Set), a gain or matrix grid event updates + the heard values but does not retarget that column; it sets the column's owed bit. At the jump's end + the column composes again; if the bits differ it lands over the value row's own `J` (`SendGain` or + `SendMatrix`, the longer one if both are owed), as #1711's deferred jump. A free column retargets at + once over the event's own ramp. A gate event always retargets at once. The hold is per column, never + per cell. No `max(own, remaining)` anywhere. Gates: a gain ride across an unmute fade ends the fade at + exactly `J` and lands at its end; a ride on the left column keeps moving while the right column fades. +- **R19-4.** Events of several curves at one offset take the longest jump among them. Gate it. +- **R19-5.** A live send move (hand offset or a D6 matrix edit) in flight at a Reposition is exact under + R19-3 and #1711's deferred jump, with no flag. If its gate fails, a send-only conservative + `TransitionFallback` term (the B17-R14 pattern) is added, and root is told. +- **K1, the shared walk.** The route table uses the merged walk of `crates/automation/src/compose.rs` + (built after #1711, before T3). The composition (law, matrix, gate) stays in the route op. The cursor + table is a boxed slice sized at bind and charged by `size_of`, not a fixed array. +- **Two PRs.** This issue ships in batch 19a with #1711 and K1. diff --git a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md index 47acabb30..dc344e299 100644 --- a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md +++ b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md @@ -371,3 +371,18 @@ Program cell order: fader cells, mute cells, #1654's route cells, then the VCA c row, lane), so each earlier session builds its program cell for cell. If #1654 generalizes `follow_walk` to a table of cursors, this slice adds the VCA mute cursors to that table and writes no second walk. + +## Rulings (root, batch 19, 2026-10-17) + +These override the batch 19 spec-check amendment where they differ. + +- **R19-2 = (b).** One composed `CellState` per output (a member fader channel, a member mute channel) + over all its curves: the merged walk of `crates/automation/src/compose.rs` (K1, built in batch 19a), + sum for offsets, OR for mutes. The route op adds the VCA mute curves as more cursors in #1654's route + table, ORed into the follow term; no second walk. Fallback (c) if U3 fails its second fix round: VCA + mute ships, VCA offset automation goes to phase 2; root is told. +- **R19-4.** Events of several curves at one offset take the longest jump among them. Gate it. +- **R19-6.** Reach-pair memory is charged as bank payload under the existing caps; no new host cap. A + gate at the cap: one pair over is refused with a typed diagnostic, not a panic. +- **Two PRs.** This issue ships alone in batch 19b, on top of 19a (#1711, K1, #1654). Edit order: every + shared file after 19a. 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 c8ae26983..e404c47a9 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 @@ -279,3 +279,13 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af hook). - This spec. - Nothing under `crates/engine/src/realtime/`. + +## Rulings (root, batch 19, 2026-10-17) + +- **R19-1.** Confirmed: for a moved target with `remaining >= J`, the old ramp runs to its end, then + the cell jumps over `J`. Today the ramp is retargeted over `remaining`; D1 changes that. A gate holds + it (red if the ramp is retargeted at the swap). +- **R19-2 (shared with #1655).** The deferred jump acts on one composed `CellState` per output. K1 + (`crates/automation/src/compose.rs`, after this issue) reuses the decisions this issue adds; events.rs + exposes them `pub(crate)`, no copy. +- **Two PRs.** This issue ships in batch 19a with K1 and #1654. diff --git a/.github/ISSUE_SPECS/1713-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 index d455a5865..d50d5102d 100644 --- a/.github/ISSUE_SPECS/1713-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 @@ -57,3 +57,10 @@ field and its join line, the control plane's `follow_into_six_word` term, and th `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. + +## Note (controls coordinator, batch 19, 2026-10-17) + +With #1654, a route with only route-row automation (rows 1-6, no source mute entry) also uses the +seven-word cell. Its seven-to-six carry (its last route entry removed) is flagged by the existing +conservative flag (`resolve_route_carry` reads `GraphLiveRoute::following`); no new code. This issue's +exact form must also cover that case. From dfe716d2d03f0192f1a9c9e5a28a9952d231dd02 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 12:42:26 +0000 Subject: [PATCH 03/36] Render send rows in the controls rules and store a hand send gain as an offset (#1654 T1) Route rows 1-6 of a route into a submix join automation_row_renders; route gain_db takes the hand offset (one shared value; differing left/right bits are refused control.automation.offset); the in-place owner rule covers route row 1; the listing reports the offset and automated = true. The host-core lowering and render come in later tranches. Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 40 +++ crates/controls/src/automation.rs | 121 ++++++-- crates/controls/src/lib.rs | 6 +- crates/controls/src/mask.rs | 31 +- crates/controls/src/owner_edits.rs | 20 +- crates/controls/src/registry.rs | 22 +- crates/controls/src/route.rs | 94 +++++- crates/controls/src/view.rs | 21 ++ crates/controls/tests/automation.rs | 271 +++++++++++++++++- 9 files changed, 576 insertions(+), 50 deletions(-) diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 2b6065987..27233fc35 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -395,3 +395,43 @@ These override the batch 19 spec-check amendment where they differ. (built after #1711, before T3). The composition (law, matrix, gate) stays in the route op. The cursor table is a boxed slice sized at bind and charged by `size_of`, not a fixed array. - **Two PRs.** This issue ships in batch 19a with #1711 and K1. + +## Decision record: T1 (batch 19) + +`crates/controls` only. What shipped: + +- **Rows (D3).** `automation_row_renders` admits route rows 1-6 through the new `route_row` + (`Owner::Route`, `Section::Own`, `ROUTE_AUTOMATED_ROWS`); row 7 `follows_mute` stays out (still + `AutomationRule::Excluded`). The predicate reads no model: an output route's entry is refused at + `.target` first (D8). `fader_row` is unchanged, so no route entry reaches a fader reader. +- **Offset (R17-6).** `takes_offset` = `fader_row || route_gain_row`. `offset_faults` judges a + shared row's `left_db` against the domain (at lane `both`) and refuses a `right_db` whose bits + differ from `left_db`'s at `.offset.right_db` (`+0.0` with `-0.0` is refused). A non-zero offset + on route rows 2-6 stays `control.automation.offset`. +- **Accessors.** `route_automation(model, route) -> [Option<&Automation>; 6]` (rows 1-6, first entry + per row in table order), `ModelView::route_automation` (edited entry where the view holds one), + `route_gain_row`, `route_row`, and `continued_route_rows` (the Carry paragraph's predicate, by + the rule of `continued_fader_lanes`; added here for the render tranche, not yet read). +- **Owner rule (R17-1).** `OwnerEdits::set` on route row 1 clones the row 1 entry with the route. + `route::resolve` reads an automated row 1 as the entry's `offset.left_db`; `registry::write` on + route row 1 writes both offset fields through `route::write_gain_offset` when an entry drives it, + else the static gain. The list sets `automated` on each of rows 1-6 an entry drives; row 1 lists + the offset, rows 2-6 their static values (hand mute; matrix fallback, automation wins). +- **Mask.** `mask_fader_offsets` copies the offset of every entry on a row that takes one (fader and + send gain). The static `gain_db` of a send whose row 1 is driven stays as `into` holds it + (fallback = structure, as the fader's #1650 departure 2); mute and matrix stay masked values. +- No row class or table changed: no SDK regeneration. + +Gates (`crates/controls/tests/automation.rs`): `the_send_gain_takes_one_shared_offset` (gate 8), +`send_rows_render_and_enumerate_in_row_order`, `a_hand_move_on_an_automated_send_gain_writes_its_offset` +(R17-6 write, list flag, in-place path equal to the whole `set`), and +`the_mask_copies_a_send_offset_and_keeps_its_fallback`. `cargo test --locked -p controls`, +`-p host-core --features test-support`, `-p control-plane --features test-support` and +`-p builtins-compiler`: all green. + +For T2: with this change a session whose only rendered entries are route entries enters +`prepare_fader_automation` (it builds an empty program today, tests green); a send automation edit +is now step 3 `Automation` (rebuild) in the classifier; a hand move on an automated send gain now +diffs as a route row 1 change whose value is the offset, and an automated matrix row's hand edit is a +static-value change the classifier does not yet commit `model_only` (not checked here): the route lowering (offset record, matrix `model_only`) is +host-core's work. diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index 2285075d2..b4044bd47 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -15,6 +15,7 @@ use crate::effect_rows::{has_rows, session_unit}; use crate::error::ControlError; use crate::lane::Side; use crate::registry::resolve; +use crate::route::{ROUTE_AUTOMATED_ROWS, ROUTE_GAIN_DB}; use session::Lane; /// Whether `target` names an effect instance (a console slot or an insert) whose identity has no @@ -45,29 +46,35 @@ fn effect_has_no_rows(model: &SessionModel, target: &ControlAddress) -> bool { identity.is_some_and(|identity| !has_rows(identity)) } -/// Whether the row at `target` takes a stored hand-move offset (#1650 D2, root's ruling R17-6): -/// only a strip's builtin `fader_db`. The slices that render the input trim (#1658) and the send -/// gain (#1654) add their rows here. +/// Whether the row at `target` takes a stored hand-move offset (#1650 D2, root's ruling R17-6): a +/// strip's builtin `fader_db` and a route's `gain_db` (row 1, [`route_gain_row`], #1654). Only a +/// route into a submix can hold an accepted entry on the route row (an output route's row is not +/// automatable and is refused at `.target` first, D8), so the predicate needs no model. The slice +/// that renders the input trim (#1658) adds its row here. pub(crate) fn takes_offset(target: &ControlAddress) -> bool { - matches!(target.owner, Owner::Strip(_)) - && target.section == Section::Builtins - && target.control == FADER_DB + fader_row(target) || route_gain_row(target) } /// The offset rule of one entry whose target is accepted (#1650 D2), per lane, left then right: /// on a lane that takes no offset (the entry does not drive it, or its row takes none) the /// offset is exactly `+0.0`, else `control.automation.offset`; on a driven lane of a row that -/// takes one, the offset lies in the row's own domain (`fader_db`: `[-144, 24]` dB, the VCA -/// offset domain), else `control.domain`. `-0.0` is a valid zero only on a lane that takes an +/// takes one, the offset lies in the row's own domain (`fader_db` and a send's `gain_db`: +/// `[-144, 24]` dB), else `control.domain`. `-0.0` is a valid zero only on a lane that takes an /// offset, so a lane without one has one spelling. +/// +/// A shared row (a send's `gain_db`, R17-6) holds one offset in two fields: `left_db` is judged +/// against the domain, and `right_db` is `control.automation.offset` when its bits differ from +/// `left_db`'s (so `+0.0` and `-0.0` differ), else accepted with it. fn offset_faults( automation: &Automation, descriptor: &ControlDescriptor, ) -> impl Iterator { let target = &automation.target; let takes = takes_offset(target); + let shared = descriptor.lanes == Lanes::Shared; + let left = automation.offset.left_db; [ - (Lane::Left, automation.offset.left_db, ".offset.left_db"), + (Lane::Left, left, ".offset.left_db"), (Lane::Right, automation.offset.right_db, ".offset.right_db"), ] .into_iter() @@ -75,7 +82,10 @@ fn offset_faults( let driven = target.lane == lane || target.lane == Lane::Both; let fault = if !(takes && driven) { (value.to_bits() != 0.0_f32.to_bits()).then_some(ControlError::AutomationOffset) + } else if shared && lane == Lane::Right { + (value.to_bits() != left.to_bits()).then_some(ControlError::AutomationOffset) } else { + let lane = if shared { Lane::Both } else { lane }; check(descriptor, lane, value, None) .err() .map(|_| ControlError::Domain) @@ -116,9 +126,11 @@ pub struct ControlDiagnostic { /// `input_filter_order`). It is reported once per lane, at the LPF entry's segment when the LPF /// is automated, else at the HPF entry's; /// - `control.automation.offset`: a hand-move offset (#1650 D2) that is not `+0.0` on a lane the -/// entry does not drive, or on any lane of a row that takes no offset (only `fader_db` takes -/// one); `control.domain` for an offset outside the fader row's domain. Each is reported at -/// `.offset.left_db` or `.offset.right_db`, after the entry's segments. +/// entry does not drive, or on any lane of a row that takes no offset (only `fader_db` and a +/// send's `gain_db` take one, R17-6), or a send `gain_db` entry whose `right_db` bits differ +/// from its `left_db` (the shared row holds one offset); `control.domain` for an offset outside +/// the row's domain. Each is reported at `.offset.left_db` or `.offset.right_db`, after the +/// entry's segments. /// /// A target is reported once, at `.target`; its segments are judged only when the target is /// accepted. A segment is reported once, at `.segments[]`: a domain fault first, else a shape @@ -389,16 +401,87 @@ pub const MUTE_CELL_RULE: CellRule = CellRule { 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. +/// Whether a stored automation of `target` renders (#1646, #1647, #1651, #1654): the fader row of +/// a strip (builtin row 5, [`fader_row`]), its mute row (builtin row 6, [`mute_row`]) and a send's +/// rows 1-6 (`gain_db`, `mute`, `matrix_*`, [`route_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): the mute row 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): neither the mute row +/// nor a route row gives a lane a fader cell, a fader hand-move offset or B17-R14's window. #[must_use] pub fn automation_row_renders(target: &ControlAddress) -> bool { - fader_row(target) || mute_row(target) + fader_row(target) || mute_row(target) || route_row(target) +} + +/// Whether `target` is one of a route's rendered rows 1-6 (`gain_db`, `mute`, `matrix_ll`, +/// `matrix_lr`, `matrix_rl`, `matrix_rr`; #1654 D3). Row 7 `follows_mute` is not automatable +/// (#1632) and is not one. The predicate reads no model: an entry on a route into the output is +/// refused at `.target` before it is judged (D8), so an accepted entry it admits is a send's. +#[must_use] +pub fn route_row(target: &ControlAddress) -> bool { + matches!(target.owner, Owner::Route(_)) + && target.section == Section::Own + && ROUTE_AUTOMATED_ROWS.contains(&target.control) +} + +/// Whether `target` is a route's `gain_db` row (row 1): the one route row that takes a hand-move +/// offset (root's ruling R17-6). The send's offset readers and writers test this predicate. +#[must_use] +pub fn route_gain_row(target: &ControlAddress) -> bool { + matches!(target.owner, Owner::Route(_)) + && target.section == Section::Own + && target.control == ROUTE_GAIN_DB +} + +/// The stored automation entries that drive route `route`'s rendered rows, in row order +/// (`[gain_db, mute, matrix_ll, matrix_lr, matrix_rl, matrix_rr]`, rows 1-6, #1654): the first +/// entry per row in table order. Every route row is shared, so an entry drives its row whole. The +/// one enumeration that preparation, the classifier and owner edits read. An empty table returns +/// at once. +#[must_use] +pub fn route_automation<'a>( + model: &'a SessionModel, + route: &StableId, +) -> [Option<&'a Automation>; 6] { + let mut rows = [None; 6]; + for automation in &model.automation { + let target = &automation.target; + if !route_row(target) || !matches!(&target.owner, Owner::Route(owner) if owner == route) { + continue; + } + if let Some(row) = ROUTE_AUTOMATED_ROWS + .iter() + .position(|&control| control == target.control) + && rows[row].is_none() + { + rows[row] = Some(automation); + } + } + rows +} + +/// The rendered rows of route `route` (in [`route_automation`]'s order) that both models +/// automate with the same table, by the rule of [`continued_fader_lanes`]: such a row's cell +/// continues its carried event state verbatim, and every other carried route cell is repositioned +/// at adoption (#1654, Carry). +#[must_use] +pub fn continued_route_rows( + current: &SessionModel, + next: &SessionModel, + route: &StableId, +) -> [bool; 6] { + if current.automation.is_empty() || next.automation.is_empty() { + return [false; 6]; + } + let (before, after) = ( + route_automation(current, route), + route_automation(next, route), + ); + core::array::from_fn(|row| { + matches!((before[row], after[row]), (Some(before), Some(after)) + if same_segments(before, after)) + }) } /// Whether `target` is a strip's builtin `mute` row (row 6): the rendered row whose entry gives a diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index afa3fa98c..0b07ebe16 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -47,9 +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, - continued_mute_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, continued_route_rows, fader_automation, fader_row, + filter_statics_changed_under_automation, filter_under_automation, mute_automation, mute_row, + rendered_automation_changed, route_automation, route_gain_row, route_row, 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 7ac5a6e88..0c167dbcb 100644 --- a/crates/controls/src/mask.rs +++ b/crates/controls/src/mask.rs @@ -51,7 +51,11 @@ struct Target<'a> { /// 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. +/// a difference of structure. A send whose `gain_db` a route-gain entry ([`crate::route_gain_row`]) +/// of `into` drives is masked the same way (R17-6): the entry's offset is copied and the route's +/// static `gain_db` is left as `into` holds it. A send's automated `mute` and matrix rows keep their +/// static values as control values (a hand mute ORs with the curve; a matrix fallback is stored by +/// a hand edit), so they are copied. /// /// After this call, `into` and `from` differ only in what is not a control value, and in the /// owners they do not share. @@ -111,9 +115,21 @@ pub fn mask_values(into: &mut SessionModel, from: &SessionModel) { ); } } + // The routes whose `gain_db` a route-gain entry of `into` drives (R17-6): their static gain is + // the fallback, not a control value, and stays as `into` holds it. + let gain_driven: Vec = if into.automation.is_empty() { + Vec::new() + } else { + into.routes + .iter() + .map(|route| crate::automation::route_automation(into, &route.id)[0].is_some()) + .collect() + }; for (index, route) in into.routes.iter_mut().enumerate() { if let Some(source) = pair(&from.routes, index, |candidate| candidate.id == route.id) { - route.gain_db = source.gain_db; + if !gain_driven.get(index).copied().unwrap_or(false) { + route.gain_db = source.gain_db; + } route.mute = source.mute; route.channel_matrix = source.channel_matrix.clone(); route.follows_mute = source.follows_mute; @@ -131,13 +147,14 @@ 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 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. +/// Copy the hand-move offset of every entry on a row that takes one (a fader-row entry, +/// [`crate::fader_row`], and a send-gain entry, [`crate::route_gain_row`], R17-6) 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, or of the send's `gain_db`. An entry +/// on another rendered row (a mute row, a send matrix row) 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::fader_row(&entry.target) { + if !crate::automation::takes_offset(&entry.target) { continue; } if let Some(prior) = pair(&from.automation, index, |candidate| { diff --git a/crates/controls/src/owner_edits.rs b/crates/controls/src/owner_edits.rs index 44d529a19..a9616c730 100644 --- a/crates/controls/src/owner_edits.rs +++ b/crates/controls/src/owner_edits.rs @@ -10,7 +10,8 @@ //! that drives the lane (#1650 D3), so that entry is an owner of the edit too (root's ruling //! R17-1 = A): a `fader_db` write clones the rendered entries that drive the strip's fader lanes //! with the strip, and [`crate::set`] on the owners model reads and writes them as it would on the -//! whole model. +//! whole model. A send's `gain_db` takes the same rule (root's ruling R17-6): a write to route row +//! 1 clones the rendered entry that drives it with the route. use session::{OwnerSlots, SessionModel}; @@ -95,6 +96,11 @@ impl<'m> OwnerEdits<'m> { { self.take_fader_automation(strip); } + if let Owner::Route(route) = &address.owner + && crate::automation::route_gain_row(address) + { + self.take_route_gain_automation(route); + } crate::registry::set(&mut self.owners, address, value, ramp, sample_rate) .map_err(OwnerEditError::Control) } @@ -114,6 +120,18 @@ impl<'m> OwnerEdits<'m> { } } + /// Clone the rendered entry that drives route `route`'s `gain_db` into the owners model, in ID + /// order, unless it is there already (R17-6, R17-1 = A on the send gain). A scan of the + /// committed table, with an early out when it is empty. + fn take_route_gain_automation(&mut self, route: &session::StableId) { + if self.committed.automation.is_empty() { + return; + } + if let Some(entry) = crate::automation::route_automation(self.committed, route)[0] { + insert_sorted(&mut self.owners.automation, entry, |entry| &entry.id); + } + } + /// Clone the owner into the owners model, in ID order, unless it is there already. fn take(&mut self, owner: &Owner) -> Result<(), OwnerEditError> { let unknown = OwnerEditError::Control(ControlError::AddressUnknown); diff --git a/crates/controls/src/registry.rs b/crates/controls/src/registry.rs index c8a9160e5..a850a9c24 100644 --- a/crates/controls/src/registry.rs +++ b/crates/controls/src/registry.rs @@ -23,10 +23,12 @@ 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): 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). + /// Whether a rendered stored automation drives the row's lane (#1650 D4): the `fader_db` row, + /// since #1651 (root ruling R18-2) the `mute` row, and since #1654 a send's rows 1-6. On such + /// a lane the `fader_db` row's value is the driving entry's hand-move offset, not the static + /// fader, and so is a send's `gain_db` (R17-6); the `mute` rows' values stay the static (hand) + /// mute, which render ORs with the curve (OQ1 = C), and a send matrix row's value is its + /// static fallback (automation wins). pub automated: bool, } @@ -239,7 +241,13 @@ pub fn owner_rows_in( ); } OwnerSlot::Route(index) => { - route::push_rows(out, view.route(index), effective.route_gate(index)); + let route = view.route(index); + route::push_rows( + out, + route, + view.route_automation(&route.id), + effective.route_gate(index), + ); } OwnerSlot::Vca(index) => vca::push_rows(out, view.vca(index)), OwnerSlot::Session => session_rows::push_rows(out, model), @@ -578,6 +586,10 @@ fn write( effect_rows::write(model, address, value) } (Owner::Route(id), Section::Own) => { + // An automated send gain's value is its entry's hand-move offset (R17-6). + if control == route::ROUTE_GAIN_DB && route::write_gain_offset(model, id, value) { + return Ok(()); + } let target = model .routes .iter_mut() diff --git a/crates/controls/src/route.rs b/crates/controls/src/route.rs index 77edf156c..9e92c4901 100644 --- a/crates/controls/src/route.rs +++ b/crates/controls/src/route.rs @@ -6,8 +6,8 @@ use effect_contract::{ParameterLattice, ParameterUnit}; use session::{ - ROUTE_COEFFICIENT_MAGNITUDE_MAXIMUM, ROUTE_GAIN_DB_MAXIMUM, ROUTE_GAIN_DB_MINIMUM, Route, - RouteDestination, SessionModel, StableId, + Automation, ROUTE_COEFFICIENT_MAGNITUDE_MAXIMUM, ROUTE_GAIN_DB_MAXIMUM, ROUTE_GAIN_DB_MINIMUM, + Route, RouteDestination, SessionModel, StableId, }; use crate::descriptor::{ @@ -15,7 +15,7 @@ use crate::descriptor::{ }; use crate::error::ControlError; use crate::lane::{Side, toggle}; -use crate::registry::{ControlRow, Resolved, Source, push_control}; +use crate::registry::{ControlRow, Resolved, Source, push_control_automated}; use crate::{ControlClass, ControlKind, Family, LiveRampRow, RebuildReason, Taper}; use session::{Owner, Section}; @@ -26,6 +26,12 @@ const MATRIX_LR: u32 = 4; const MATRIX_RL: u32 = 5; const MATRIX_RR: u32 = 6; const FOLLOWS_MUTE: u32 = 7; +/// The ID of the route's `gain_db` row, the one route row that takes a hand-move offset (R17-6). +pub(crate) const ROUTE_GAIN_DB: u32 = GAIN_DB; +/// The route rows a stored automation renders on a send, in their cell order (#1654 D3): rows 1-6. +/// Row 7 `follows_mute` is not automatable (#1632). +pub(crate) const ROUTE_AUTOMATED_ROWS: [u32; 6] = + [GAIN_DB, MUTE, MATRIX_LL, MATRIX_LR, MATRIX_RL, MATRIX_RR]; /// The own-row ID of `effective_gate`. const EFFECTIVE_GATE: u32 = 64; @@ -230,33 +236,72 @@ fn descriptor(row: &RouteRow, route: &Route) -> ControlDescriptor { } } -/// Append every control row of one route. `gate` is the route's effective gate, per source lane. -pub(crate) fn push_rows(out: &mut Vec, route: &Route, gate: [bool; 2]) { - push_route_rows(out, route, gate, false); +/// The stored automation entries that drive a route's rendered rows +/// ([`crate::route_automation`], or [`crate::ModelView::route_automation`]), in row order. +pub(crate) type RouteEntries<'a> = [Option<&'a Automation>; 6]; + +/// Where the rendered row `control` sits in [`RouteEntries`], or `None` for a row no entry renders. +fn automated_index(control: u32) -> Option { + ROUTE_AUTOMATED_ROWS.iter().position(|&row| row == control) +} + +/// The committed value of a stored row: the row's model field, except that an automated `gain_db` +/// reads its entry's hand-move offset (R17-6; the static gain is the fallback, not heard). +fn stored_value( + read: fn(&Route) -> f32, + route: &Route, + control: u32, + entries: RouteEntries, +) -> f32 { + match entries[0] { + Some(entry) if control == GAIN_DB => entry.offset.left_db, + _ => read(route), + } +} + +/// Append every control row of one route. `entries` are the route's driving entries, `gate` its +/// effective gate per source lane. A row an entry drives lists `automated = true` (#1650 D4 on the +/// send); an automated `gain_db` lists its entry's offset as its value, an automated `mute` or +/// matrix row its static (hand or fallback) value. +pub(crate) fn push_rows( + out: &mut Vec, + route: &Route, + entries: RouteEntries, + gate: [bool; 2], +) { + push_route_rows(out, route, entries, gate, false); } /// Append only a route's derived rows (its effective gate), in listing order. pub(crate) fn push_derived_rows(out: &mut Vec, route: &Route, gate: [bool; 2]) { - push_route_rows(out, route, gate, true); + push_route_rows(out, route, [None; 6], gate, true); } -fn push_route_rows(out: &mut Vec, route: &Route, gate: [bool; 2], derived_only: bool) { +fn push_route_rows( + out: &mut Vec, + route: &Route, + entries: RouteEntries, + gate: [bool; 2], + derived_only: bool, +) { let owner = Owner::Route(route.id.clone()); for row in &ROWS { let descriptor = descriptor(row, route); if derived_only && descriptor.class != ControlClass::Derived { continue; } - push_control( + let automated = automated_index(row.id).is_some_and(|index| entries[index].is_some()); + push_control_automated( out, &owner, &Section::Own, row.id, &descriptor, |side| match &row.value { - RouteValue::Stored { read, .. } => read(route), + RouteValue::Stored { read, .. } => stored_value(*read, route, row.id, entries), RouteValue::Derived { read } => read(gate, side), }, + |_| automated, ); } } @@ -277,8 +322,15 @@ pub(crate) fn resolve( .find(|row| row.id == control) .ok_or(ControlError::AddressUnknown)?; let descriptor = descriptor(row, route); + let entries = if control == GAIN_DB { + crate::automation::route_automation(model, id) + } else { + [None; 6] + }; Ok(match &row.value { - RouteValue::Stored { read, .. } => Resolved::stored(descriptor, |_| read(route)), + RouteValue::Stored { read, .. } => { + Resolved::stored(descriptor, |_| stored_value(*read, route, control, entries)) + } RouteValue::Derived { read } => Resolved { descriptor, source: Source::RouteDerived(*read), @@ -286,6 +338,26 @@ pub(crate) fn resolve( }) } +/// Write `value` as the hand-move offset (both fields, one value) of the entry of `model` that +/// drives route `route`'s `gain_db` (R17-6), and return whether one does; when none does, the +/// caller writes the static gain. The caller has run [`crate::check`] with the row's own domain, +/// `[-144, 24]` dB, which is the offset domain. +pub(crate) fn write_gain_offset(model: &mut SessionModel, route: &StableId, value: f32) -> bool { + if model.automation.is_empty() { + return false; + } + let Some(id) = + crate::automation::route_automation(model, route)[0].map(|entry| entry.id.clone()) + else { + return false; + }; + if let Some(entry) = model.automation.iter_mut().find(|entry| entry.id == id) { + entry.offset.left_db = value; + entry.offset.right_db = value; + } + true +} + /// Write one of a route's rows. pub(crate) fn write(route: &mut Route, control: u32, value: f32) -> Result<(), ControlError> { match ROWS.iter().find(|row| row.id == control) { diff --git a/crates/controls/src/view.rs b/crates/controls/src/view.rs index 1803baca9..33cbe2d07 100644 --- a/crates/controls/src/view.rs +++ b/crates/controls/src/view.rs @@ -120,6 +120,27 @@ impl<'a> ModelView<'a> { }) } + /// The rendered stored automation entries that drive route `route`'s rows 1-6, in row order + /// ([`crate::route_automation`]), each the edited entry when the view holds one (R17-6, R17-1): + /// a hand move on an automated send gain writes the driving entry's offset, so the binding of + /// the route's `gain_db` row reads the entry here. Which entry drives a row is read from the + /// committed table, and only its offset from the edit, as for [`Self::fader_automation`]. + #[must_use] + pub fn route_automation(&self, route: &StableId) -> [Option<&'a Automation>; 6] { + if self.structure.automation.is_empty() { + return [None; 6]; + } + let rows = crate::automation::route_automation(self.structure, route); + if self.is_whole() { + return rows; + } + rows.map(|entry| { + entry.map(|entry| { + find(&self.owners.automation, &entry.id, |held| &held.id).unwrap_or(entry) + }) + }) + } + /// The dense model the view stands for: a clone of the committed model with the edited owners /// in place. Linear in the session; for tests and for a caller that has no view-shaped code. #[must_use] diff --git a/crates/controls/tests/automation.rs b/crates/controls/tests/automation.rs index b59acc2b7..d1f716a90 100644 --- a/crates/controls/tests/automation.rs +++ b/crates/controls/tests/automation.rs @@ -683,9 +683,10 @@ fn with_offset(automation: Automation, left_db: f32, right_db: f32) -> Automatio } /// #1650a gate 1 (D2, root's ruling R17-6): the hand-move offset belongs to a lane the entry -/// drives on the one row that takes it, `fader_db`, inside that row's `[-144, 24]` dB. A lane the -/// entry does not drive, and every lane of any other row (the input trim and the send gain until -/// #1658 and #1654 add them, a pan), hold exactly `+0.0`, else `control.automation.offset`; an +/// drives on a row that takes it (here `fader_db`; the send gain is +/// `the_send_gain_takes_one_shared_offset`), inside that row's `[-144, 24]` dB. A lane the entry +/// does not drive, and every lane of a row that takes none (the input trim until #1658, a pan), +/// hold exactly `+0.0`, else `control.automation.offset`; an /// offset outside the domain is `control.domain`. Each is reported at its lane's path, after the /// entry's segments. /// @@ -738,7 +739,8 @@ fn the_offset_belongs_to_a_driven_fader_lane() { "control.domain", ".offset.left_db", ), - // Rows that take no offset (R17-6): the trim, a send's gain, a pan. + // Rows that take no offset (R17-6): the trim, a pan; and a send gain whose two fields + // differ (its shared row holds one offset, #1654). ( with_offset( one( @@ -887,3 +889,264 @@ fn a_mute_entry_reaches_no_fader_only_reader() { 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()); } + +const ROUTE_MUTE: u32 = 2; +const ROUTE_MATRIX_LL: u32 = 3; +const ROUTE_MATRIX_LR: u32 = 4; +const ROUTE_MATRIX_RR: u32 = 6; + +/// #1654 gate 8 (root's ruling R17-6): a send's `gain_db` takes one hand-move offset, held in both +/// fields of its shared row, inside `[-144, 24]` dB; `right_db` whose bits differ from `left_db`'s +/// is `control.automation.offset` at `.offset.right_db` (so `+0.0` with `-0.0` is refused, and an +/// out-of-domain pair is one `control.domain` at `.offset.left_db`). The send's `mute` and matrix +/// rows take no offset: a non-zero field there is `control.automation.offset`. +/// +/// Red if the send gain still takes no offset (the accepted cases are refused), if its two fields +/// may differ (render reads one offset, so the other field would be acked and never heard), if a +/// shared row's domain check uses a single lane (every accepted case turns `control.domain`), or +/// if the offset reaches the send's mute or matrix rows. +#[test] +fn the_send_gain_takes_one_shared_offset() { + let send = |name: &str, control: u32, unit: ParameterUnit, value: f32| { + one(name, route_own("send", control), unit, value) + }; + let gain = |name: &str| send(name, GAIN_DB, ParameterUnit::Db, -6.0); + for accepted in [ + with_offset(gain("plus-three"), 3.0, 3.0), + with_offset(gain("negative-zero"), -0.0, -0.0), + with_offset(gain("top"), 24.0, 24.0), + with_offset(gain("bottom"), -144.0, -144.0), + ] { + let name = accepted.id.as_str().to_owned(); + assert_eq!(judged(vec![accepted]), [], "{name}"); + } + let refused = [ + ( + with_offset(gain("zeros-differ"), 0.0, -0.0), + "control.automation.offset", + ".offset.right_db", + ), + ( + with_offset(gain("fields-differ"), 1.0, 2.0), + "control.automation.offset", + ".offset.right_db", + ), + ( + with_offset(gain("above"), 30.0, 30.0), + "control.domain", + ".offset.left_db", + ), + ( + with_offset( + send("matrix", ROUTE_MATRIX_LR, ParameterUnit::Linear, 0.5), + 0.5, + 0.0, + ), + "control.automation.offset", + ".offset.left_db", + ), + ( + with_offset( + send("mute", ROUTE_MUTE, ParameterUnit::Linear, 1.0), + 0.0, + -0.0, + ), + "control.automation.offset", + ".offset.right_db", + ), + ]; + for (entry, code, suffix) in refused { + let name = entry.id.as_str().to_owned(); + assert_eq!( + judged(vec![entry]), + [(code, format!("$.automation[id={name}]{suffix}"))], + "{name}" + ); + } +} + +/// #1654 D3: a send's rows 1-6 render ([`controls::automation_row_renders`]) and `follows_mute` +/// does not; only `gain_db` is the send's offset row; none reaches a fader-only reader +/// ([`controls::fader_row`]); [`controls::route_automation`] gives each row its first entry, in +/// row order; [`controls::automated_cell`] holds on a driven row; and +/// [`controls::continued_route_rows`] holds on a row whose table both models keep. +/// +/// Red if a send row stays masked from rendering, if `follows_mute` joins the list, if the route +/// enumeration misplaces a row, or if an entry of another route is read. +#[test] +fn send_rows_render_and_enumerate_in_row_order() { + use controls::{ + automated_cell, automation_row_renders, continued_route_rows, fader_row, route_automation, + route_gain_row, route_row, + }; + for control in [GAIN_DB, ROUTE_MUTE, ROUTE_MATRIX_LL, 5, ROUTE_MATRIX_RR] { + let target = route_own("send", control); + assert!(route_row(&target) && automation_row_renders(&target)); + assert!(!fader_row(&target)); + assert_eq!(route_gain_row(&target), control == GAIN_DB); + } + let follows = route_own("send", FOLLOWS_MUTE); + assert!(!route_row(&follows) && !automation_row_renders(&follows)); + + let mut model = effects_fixture(); + model.automation = vec![ + one( + "lr", + route_own("send", ROUTE_MATRIX_LR), + ParameterUnit::Linear, + 0.25, + ), + one("gain", route_own("send", GAIN_DB), ParameterUnit::Db, -6.0), + one( + "other", + route_own("bus-out", GAIN_DB), + ParameterUnit::Db, + -6.0, + ), + ]; + let rows = route_automation(&model, &id("send")); + let names = rows.map(|entry| entry.map(|entry| entry.id.as_str().to_owned())); + assert_eq!( + names, + [ + Some("gain".to_owned()), + None, + None, + Some("lr".to_owned()), + None, + None + ] + ); + assert!(automated_cell(&model, &route_own("send", ROUTE_MATRIX_LR))); + assert!(!automated_cell(&model, &route_own("send", ROUTE_MATRIX_LL))); + + let mut next = model.clone(); + next.automation[0].segments[0].start_value = 0.5; + next.automation[0].segments[0].end_value = 0.5; + assert_eq!( + continued_route_rows(&model, &next, &id("send")), + [true, false, false, false, false, false] + ); +} + +/// The `send` session with a gain ride and a `matrix_lr` ride on the send. +fn ridden_send() -> SessionModel { + let mut model = effects_fixture(); + model.automation = vec![ + one("gain", route_own("send", GAIN_DB), ParameterUnit::Db, -6.0), + one( + "lr", + route_own("send", ROUTE_MATRIX_LR), + ParameterUnit::Linear, + 0.25, + ), + ]; + model +} + +/// #1654 R17-6 and R17-1 at the controls level: a `SET_CONTROL` on an automated send gain writes +/// the driving entry's offset (both fields, one value) and leaves the static gain (the fallback); +/// `get` and the list report the offset as the row's value with `automated = true`; an automated +/// matrix row keeps automation-wins (the hand value is the stored fallback, listed `automated`); +/// an un-automated row lists `automated = false`. The in-place path ([`controls::OwnerEdits`], +/// read through [`controls::ModelView`]) gives the dense model and the dense rows of the whole +/// `set`. +/// +/// Red if a hand send gain move writes the static gain (automation wins, the move is acked and +/// never heard), if it writes one field only, if the list hides the offset or the flag, or if the +/// in-place path does not clone the entry with the route (its write would be lost on the owners +/// model). +#[test] +fn a_hand_move_on_an_automated_send_gain_writes_its_offset() { + use controls::{ + ModelView, MonitorOverlay, OwnerEdits, effective, get, owner_index, owner_rows_in, + owner_rows_with, set, + }; + let committed = ridden_send(); + let gain = route_own("send", GAIN_DB); + let lr = route_own("send", ROUTE_MATRIX_LR); + let ll = route_own("send", ROUTE_MATRIX_LL); + let route = |model: &SessionModel| { + model + .routes + .iter() + .find(|route| route.id == id("send")) + .cloned() + .expect("send") + }; + let static_gain = route(&committed).gain_db; + + let mut whole = committed.clone(); + set(&mut whole, &gain, 3.0, None, RATE).expect("gain"); + set(&mut whole, &lr, 0.75, None, RATE).expect("matrix"); + assert_eq!(whole.automation[0].offset.left_db, 3.0); + assert_eq!(whole.automation[0].offset.right_db, 3.0); + assert_eq!(route(&whole).gain_db.to_bits(), static_gain.to_bits()); + assert_eq!(route(&whole).channel_matrix.lr, 0.75); + let overlay = MonitorOverlay::default(); + assert_eq!(get(&whole, &overlay, &gain, RATE), Ok(3.0)); + let gain_row = common::row_of(&whole, &gain); + assert!(gain_row.automated && gain_row.value == 3.0); + let lr_row = common::row_of(&whole, &lr); + assert!(lr_row.automated && lr_row.value == 0.75); + assert!(!common::row_of(&whole, &ll).automated); + + // A send no entry drives keeps the static write. + let mut plain = effects_fixture(); + set(&mut plain, &gain, 3.0, None, RATE).expect("plain gain"); + assert_eq!(route(&plain).gain_db, 3.0); + assert!(!common::row_of(&plain, &gain).automated); + + // The in-place path: the same writes on the owners model. + let mut edits = OwnerEdits::new(&committed); + edits.set(&gain, 3.0, None, RATE).expect("in-place gain"); + edits.set(&lr, 0.75, None, RATE).expect("in-place matrix"); + let (owners, slots) = edits.finish(); + assert_eq!(owners.automation.len(), 1, "the gain entry alone is cloned"); + assert_eq!(slots.automation, [(0, 0)]); + let view = ModelView::edited(&committed, &owners); + assert_eq!(view.to_model(), whole); + assert_eq!( + view.route_automation(&id("send"))[0].map(|entry| entry.offset.left_db), + Some(3.0) + ); + let owner = owner_index(&committed, &controls::Owner::Route(id("send"))).expect("send"); + let dense = effective(&whole, &overlay); + let (mut dense_rows, mut view_rows) = (Vec::new(), Vec::new()); + owner_rows_with(&whole, &dense, &overlay, owner, RATE, &mut dense_rows); + owner_rows_in(view, &dense, &overlay, owner, RATE, &mut view_rows); + assert_eq!(view_rows, dense_rows); +} + +/// #1654 mask treatment: [`controls::mask_values`] copies a send-gain entry's offset as a control +/// value and keeps the static gain of an automated send as structure (its fallback), while an +/// un-automated send's gain and an automated send's matrix fallback stay control values. +/// +/// Red if a send offset change is seen as structure (a live hand move would rebuild), or if a +/// change of an automated send's static gain is hidden as a value (it would never rebuild). +#[test] +fn the_mask_copies_a_send_offset_and_keeps_its_fallback() { + use controls::mask_values; + let mut from = ridden_send(); + from.automation[0].offset.left_db = 2.0; + from.automation[0].offset.right_db = 2.0; + for route in &mut from.routes { + route.gain_db = -9.0; + route.channel_matrix.lr = 0.5; + } + let mut into = ridden_send(); + let kept = into + .routes + .iter() + .find(|route| route.id == id("send")) + .expect("send") + .gain_db; + mask_values(&mut into, &from); + assert_eq!(into.automation[0].offset.left_db, 2.0); + assert_eq!(into.automation[0].offset.right_db, 2.0); + for route in &into.routes { + let expected = if route.id == id("send") { kept } else { -9.0 }; + assert_eq!(route.gain_db.to_bits(), expected.to_bits(), "{}", route.id); + assert_eq!(route.channel_matrix.lr, 0.5); + } +} From a7f59058ebadbf7341656f7f17d63b8bc0ef1bd2 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 12:47:34 +0000 Subject: [PATCH 04/36] Compare a free fader stage's ramp target at a Reposition adoption (#1711 F1) settle_adoption compared the curve with fader_gain, which RememberGain moves while grid events are held, so every heard level move landed at the swap. A free stage now compares with the kernel's ramp target (BuiltinFaderBank::ramp_target); a held stage keeps fader_gain. G1 is now bit-equal for VCA and hand moves heard 1, 12, 24, 26 and 30 blocks, on the C ABI and in host-core at Simd8 and Simd4 (red without the fix). Co-Authored-By: Claude Opus 5.5 --- ...ves-own-length-across-a-reposition-swap.md | 39 +++ crates/builtins-compiler/src/lib.rs | 27 +- crates/builtins/src/lib.rs | 12 + crates/capi/src/runtime/live_tests.rs | 58 ++-- .../tests/stored_fader_automation.rs | 270 ++++++++++++++++++ 5 files changed, 362 insertions(+), 44 deletions(-) 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 e404c47a9..2de53fb07 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 @@ -289,3 +289,42 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af (`crates/automation/src/compose.rs`, after this issue) reuses the decisions this issue adds; events.rs exposes them `pub(crate)`, no copy. - **Two PRs.** This issue ships in batch 19a with K1 and #1654. + +## Decision record: F1 (batch 19) + +- **F0 shipped.** `BuiltinFaderBank::ramp_target(lane, channel)` (`crates/builtins/src/lib.rs`, over + the kernel's `target_gain`) is new. `ChannelHold::fader_gain` is renamed `stage_gain`. `bank_hold` + fills it with the ramp target on a free stage and with `fader_gain` on a held stage. The + scalar oracle still gives `None`. `settle_adoption` compares the curve with `stage_gain`. No + other behaviour changes: `long_landing`, `length.max(remaining)` and the B17-R14 flag stay + (D1 and D2 are later tranches). +- **G1, C ABI (Simd8).** `a_level_move_across_a_table_change_keeps_its_length` + (`crates/capi/src/runtime/live_tests.rs`) now asserts bit-equality with the uninterrupted engine + over 44 blocks after the swap, not 1e-3. Timings: Pending, SameTransaction, heard 1, 12, 24, 26 + and 30. VCA and hand moves, both tables: 28 cases, all bit-equal, allocation-free, adopted. +- **G1, host-core (Simd8 and Simd4).** New + `a_level_move_in_flight_across_a_table_only_rebuild_keeps_its_bits` + (`crates/host-core/tests/stored_fader_automation.rs`, `test-support`), with its own carry + harness `level_move_run` (the `carry_run` pattern: a plan exchange, a live offsets write at + block 4, a successor prepared on the committed model with the move). Heard 1, 12, 24, 26 and + 30, VCA and hand moves, both tables: every block bit-equal to the uninterrupted engine, + allocation-free, adopted at the swap block, the strip carries. `successor.rs` is not touched. +- **Red without F0** (`bank_hold` back to `fader_gain`, run once, restored): the C ABI gate fails + at "a VCA move, the ride split at 10,000, Heard(12)" (first difference block 25, sample 79); the + host-core gate fails (first difference block 28). +- **Not asserted in F1: `OUTCOME_EXACT`.** The B17-R14 window still flags the heard 24, 26 and 30 + cases (`a_reposition_swap_while_a_level_move_ramps_reports_a_fallback` is unchanged and green). + The outcome assertion of G1 belongs to the tranche that deletes the flag (D2). +- **Departure: the two-rebuild case of A3 is not in F1.** Two table-only rebuilds 5 blocks apart + around a 500-sample move (heard 2) cannot be bit-equal to the uninterrupted engine: the second + rebuild comes after the move's ramp ends, the stage is then free and settled on a grid ramp, and + the split tables' values differ from the original ride in `f32` rounding, so the cell jumps over + `J` (probe: 1.4e-4 to 1.7e-4 with no move at all; 7.7e-5 with only the later rebuild). Its + reference must be a fresh plan with the new table (G1's "where the table's own values differ" + clause), or its claim is the outcome alone. Moved to the D2 tranche, which owns the outcome + assertion and deletes the tranche-5 fallback gate it replaces. +- Gates run: `cargo fmt --all`; clippy `-D warnings` `--all-targets --all-features` on builtins, + builtins-compiler, host-core, capi; `cargo check --locked --all-targets` (no features) on the + same; tests of builtins, builtins-compiler, automation (all features), host-core + `stored_fader_automation` (31), `stored_mute_automation` (13), `successor_swap` (140), + `session_seek`, `browser_successor`, capi lib (209), host-web `control_parity` (30): all green. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 3a7859b34..9004598f8 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -1432,10 +1432,13 @@ struct ChannelHold { muted: bool, /// Frames left in the channel's ramp. remaining: u32, - /// The fader gain the stage aims for, whatever its mute (#1648 D2: an adopted cell compares - /// its curve with it). `None` on the scalar oracle, which no plan swap carries (its fader - /// owners are bound nodes, not bank lanes). - fader_gain: Option, + /// The gain an adopted cell compares its curve with (#1648 D2): on a held stage, the fader + /// gain it aims for (the one an unmute lands on); on a free stage, its ramp's target (#1711 + /// F0: the cell's held grid events only remember a gain inside a live move's ramp, so the + /// fader gain follows the ride while the target stays where the move ends). `None` on the + /// scalar oracle, which no plan swap carries (its fader owners are bound nodes, not bank + /// lanes). + stage_gain: Option, } /// One channel's block cut at the end `E` of a held ramp (#1647 D1.6, constraint 2): the piece @@ -2136,7 +2139,8 @@ struct AdoptionBlock<'p, 'a> { /// Then, for a reposition or [`Adoption::Compare`], the adoption rule: when /// the curve at the block's first sample (or that ramp's end), composed with this plan's `S` /// (`checked_fader_gain(vca_compose_db(curve, S))`, #1648 departure 4), differs in bits from the -/// gain the stage aims for, the block opens with a jump (returns its length: `J`, or the frames +/// gain the stage aims for (a free stage's ramp target, #1711 F0; a held stage's fader gain), +/// the block opens with a jump (returns its length: `J`, or the frames /// the free stage's own ramp has left when that is longer, so a running edit still ends when it /// was due, on this table's curve; never shorter than `J`, so a moved target never lands faster /// than a table change does). Otherwise a repositioned @@ -2194,7 +2198,7 @@ fn settle_adoption( } let value = curve_toward(program, span.start, at); let differs = FaderEventDriver::gain(value, offsets_sum) - .zip(hold.and_then(|hold| hold.fader_gain)) + .zip(hold.and_then(|hold| hold.stage_gain)) .is_some_and(|(gain, stage)| gain.to_bits() != stage.to_bits()); if !differs && adoption == Adoption::Reposition { state.seed_target(value); @@ -4031,11 +4035,16 @@ fn automated_lanes( /// [`FaderEventDriver::new`] (#1647 D1.6). fn bank_hold(bank: &BuiltinFaderBank) -> impl Fn(usize, usize) -> Option + '_ { |lane, channel| { + let held = bank.gain_held(lane, channel)?; Some(ChannelHold { - held: bank.gain_held(lane, channel)?, + held, muted: bank.is_muted(lane, channel)?, remaining: bank.ramp_remaining(lane, channel)?, - fader_gain: bank.fader_gain(lane, channel), + stage_gain: if held { + bank.fader_gain(lane, channel) + } else { + bank.ramp_target(lane, channel) + }, }) } } @@ -8231,7 +8240,7 @@ fn render_oracle_fader( held: fader.gain_held(channel)?, muted: fader.is_muted(channel), remaining: fader.ramp_remaining(channel)?, - fader_gain: None, + stage_gain: None, }) }, ); diff --git a/crates/builtins/src/lib.rs b/crates/builtins/src/lib.rs index 013951955..203e219c4 100644 --- a/crates/builtins/src/lib.rs +++ b/crates/builtins/src/lib.rs @@ -4648,6 +4648,18 @@ impl BuiltinFaderBank { Some(per_width!(FaderStageKernel(stage) in &self.stage => stage.fader_gain[channel][lane])) } + /// The gain a member lane's ramp on `channel` (`0` left, `1` right) runs toward: its settled + /// gain once the ramp ends. Unlike [`Self::fader_gain`], a gain the lane only remembered does + /// not move it (#1711 F0: a free carried lane's adoption compares its curve with this). + /// `None` for a padding lane or a channel above `1`. + #[must_use] + pub fn ramp_target(&self, lane: usize, channel: usize) -> Option { + if lane >= self.members || channel > 1 { + return None; + } + Some(per_width!(FaderStageKernel(stage) in &self.stage => stage.target_gain(lane, channel))) + } + /// Frames left in a member lane's ramp on `channel`; `0` when settled. `None` for a padding /// lane or a channel above `1`. #[must_use] diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index b4df19aa9..f7a654df0 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -9056,20 +9056,25 @@ mod set_control { SessionEdit::UpsertAutomation { automation } } - /// U3 fold 2 (#1648 D2, D15-7). A live level move with its own 4,000 samples on the - /// riding fader (a VCA move and a hand move), and a rebuild whose only change is the - /// ride's table: the same ride split in two at sample 10,000, or a change only in its + /// U3 fold 2 (#1648 D2, D15-7) and #1711 G1. A live level move with its own 4,000 samples + /// on the riding fader (a VCA move and a hand move), and a rebuild whose only change is + /// the ride's table: the same ride split in two at sample 10,000, or a change only in its /// last 2,000 samples. The move is pending at the swap, in the rebuild's transaction, or - /// heard 1 and 12 blocks before it. Every block of the rebuilt rig stays within 1e-3 of - /// the uninterrupted engine's over 44 blocks, allocation-free, and the successor is - /// adopted. Measured: 0 pending, in the transaction and heard 1; 4.6e-6 heard 12, where - /// the landing over the rest of the move's ramp recomputes the stage's ramp step at the - /// swap (B17-R13, NIT 5). + /// heard 1, 12, 24, 26 and 30 blocks before it (3,872, 2,464, 928, 672 and 160 samples + /// left, the last three shorter than the fader length `J` = 960). Every block of the + /// rebuilt rig is bit-equal to the uninterrupted engine's over 44 blocks, allocation-free, + /// and the successor is adopted. /// - /// Test value: red if the repositioned cell drops the hold of the ramp the free stage - /// still runs, or compares the curve at the block's first sample rather than at that - /// ramp's end with the stage's target: on a moving ride they differ, and the adoption - /// jump replaces the edit's 4,000 samples with the fader length (960; 0.047 at HEAD). + /// The watermark outcome is not asserted here: until D2 deletes the B17-R14 window, the + /// heard 24, 26 and 30 cases still report `OUTCOME_TRANSITION_FALLBACK` + /// (`a_reposition_swap_while_a_level_move_ramps_reports_a_fallback`). + /// + /// Test value: red if the adoption rule compares the curve with the stage's fader gain + /// rather than its ramp's target (#1711 F0: the held grid events only remember a gain, so + /// the fader gain follows the ride and every heard move lands at the swap; 4.6e-6 heard + /// 12, up to 3.1e-3 heard 24 to 30), if the repositioned cell drops the hold of the ramp + /// the free stage still runs, or if it compares the curve at the block's first sample + /// rather than at that ramp's end. #[test] fn a_level_move_across_a_table_change_keeps_its_length() { bench_support::alloc::assert_installed(); @@ -9091,6 +9096,9 @@ mod set_control { MoveTiming::SameTransaction, MoveTiming::Heard(1), MoveTiming::Heard(12), + MoveTiming::Heard(24), + MoveTiming::Heard(26), + MoveTiming::Heard(30), ]; for (what, edit) in &moves { for &(change, table) in &changes { @@ -9131,29 +9139,9 @@ mod set_control { label, ); } - let [a, u, _] = &mut rigs; - let mut worst = (0.0_f32, 0_u64); - for _ in 0..44 { - let block = a.block; - let (x, used) = a.step_counted(); - assert_eq!( - (used.allocations, used.deallocations), - (0, 0), - "{label}: block {block} allocated or freed" - ); - for (p, q) in x.iter().zip(&u.step()) { - if (p - q).abs() > worst.0 { - worst = ((p - q).abs(), block); - } - } - } - assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); - assert!( - worst.0 < 1e-3, - "{label}: max |a - u| {} at block {}", - worst.0, - worst.1 - ); + let (first, _) = lockstep(&mut rigs, 44); + assert_eq!(rigs[0].summary().3, 0, "{label}: the successor is adopted"); + assert_eq!(first, None, "{label}: first difference (block, sample)"); } } } diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index fdb1333e6..6e3f7b849 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -2856,6 +2856,276 @@ fn a_ride_gained_on_a_carried_static_fader_jumps_from_the_stages_gain() { } } +// #1711 G1: a live level move in flight across a table-only rebuild. + +/// The block a [`level_move_run`] commits its live move at. +#[cfg(feature = "test-support")] +const MOVE_BLOCK: usize = 4; + +/// The length of the ride of [`level_move_model`], from `0` to `-12` dB. +#[cfg(feature = "test-support")] +const RIDE_FRAMES: u64 = 48_000; + +/// The live move of [`level_move_model`]: `S` to `-6` dB over 4,000 samples. +#[cfg(feature = "test-support")] +const LEVEL_MOVE: (f64, u32) = (-6.0, 4_000); + +/// Two tracks (two bank lanes) at quantum 128 with fader length 20 ms (`J` = 960), track 0 +/// riding linearly from `0` to `-12` dB over [`RIDE_FRAMES`] (split in two at `split.0` with the +/// second segment ending at `split.1` dB when given: `-12` keeps the ride), with its entry's +/// stored offset `hand` dB and, for a VCA move, a VCA of track 0 at `vca` dB. +#[cfg(feature = "test-support")] +fn level_move_model(split: Option<(u64, f32)>, hand: f32, vca: Option) -> SessionModel { + let mut model = plain(2, 128); + model.control_smoothing = smoothing(20.0); + let lin = AutomationShape::Linear; + let segments = match split { + None => vec![segment(lin, 0, RIDE_FRAMES, 0.0, -12.0)], + Some((at, end_db)) => { + let k = -12.0 * at as f32 / RIDE_FRAMES as f32; + vec![ + segment(lin, 0, at, 0.0, k), + segment(lin, at, RIDE_FRAMES, k, end_db), + ] + } + }; + let mut entry = fader_entry(&model, 0, ParameterChannel::Both, segments); + (entry.offset.left_db, entry.offset.right_db) = (hand, hand); + model.automation = vec![entry]; + if let Some(db) = vca { + model.vcas = vec![session::Vca { + id: id("vca-move"), + fader: session::DualMonoFader { + left_db: db, + right_db: db, + left_mute: false, + right_mute: false, + }, + members: vec![model.tracks[0].id.clone()], + }]; + } + model +} + +/// What a [`level_move_run`] rendered. +#[cfg(feature = "test-support")] +struct LevelMoveRun { + /// Each block's output bits, both planes. + blocks: Vec>, + /// The render calls' `(allocations, reallocations, frees)`, summed. + allocations: (u64, u64, u64), + /// The block whose render adopted the successor, if one did. + adopted: Option, + /// The successor's `restarted_strips()`. + restarted: Vec>, +} + +/// `a` prepared with live controls on `backend` and rendered for `blocks` blocks of DC `1.0`; +/// at [`MOVE_BLOCK`]'s entry the live offsets record [`LEVEL_MOVE`] is written on both channels +/// of track 0. With `b = Some((next, committed, swap))`, `next` is prepared as the successor of +/// the plan on the committed model `committed` and swapped in at block `swap` (the C ABI's +/// carry: `adopt_persisting`, the successor's gate published at the next revision). +#[cfg(feature = "test-support")] +fn level_move_run( + a: &SessionModel, + b: Option<(&SessionModel, &SessionModel, usize)>, + backend: graph_compiler::Backend, + blocks: usize, +) -> LevelMoveRun { + use builtins_compiler::TestOnlyWriteGate; + use engine::realtime::{PlanExchangeConfig, SwapOutcome, plan_exchange}; + let quantum = a.quantum_frames as usize; + let caps = rig_caps(a); + let request = host_core::HostLiveControlRequest::default(); + let fail = |failure: host_core::PrepareDiagnostics| -> ! { + panic!("prepare: {}", String::from_utf8_lossy(failure.as_bytes())) + }; + let compiled = |model: &SessionModel| { + let document = canonical_session_json(model).expect("canonical"); + host_core::compile_host_session(&document, &caps) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))) + }; + let (prepared, mut handles) = host_core::test_only_prepare_host_runtime_with_live_controls_on( + &compiled(a), + &caps, + &request, + backend, + ) + .unwrap_or_else(|failure| fail(failure)); + let PreparedHost { + mut plan, + mut sources, + inventory, + .. + } = prepared; + let mut gate = plan.take_revision_gate().expect("the plan's 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 plane = vec![1.0_f32; quantum]; + let mut run = LevelMoveRun { + blocks: Vec::with_capacity(blocks), + allocations: (0, 0, 0), + adopted: None, + restarted: Vec::new(), + }; + let mut successor_sources = None; + let mut successor_gate = None; + for block in 0..blocks { + if let Some((next, committed, swap)) = b + && block == swap + { + let committed = compiled(committed).normalized_model().clone(); + let revision = gate.last_published() + 1; + let base = host_core::SuccessorBase::new(&inventory, &committed, revision); + let (mut prepared, next_handles) = + host_core::test_only_prepare_host_runtime_with_live_controls_successor_on( + &compiled(next), + &caps, + &request, + base, + backend, + ) + .unwrap_or_else(|failure| fail(failure)); + prepared.sources.adopt_persisting(&mut sources); + run.restarted = prepared.restarted_strips().to_vec(); + let PreparedHost { + mut plan, + sources: next_sources, + .. + } = prepared; + let mut next_gate = plan.take_revision_gate().expect("the successor's gate"); + next_gate.publish(revision); + successor_gate = Some((next_handles, next_gate)); + publisher + .reserve_replacement(plan, engine::realtime::PlanAdoption::Next) + .unwrap_or_else(|_| panic!("reserve the successor")) + .commit(); + successor_sources = Some(next_sources); + } + if block == MOVE_BLOCK { + let (offsets_sum, smoothing_samples) = LEVEL_MOVE; + 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, + }, + ); + } + }); + } + let at = block * quantum; + successor_sources + .as_mut() + .unwrap_or(&mut sources) + .submit( + NINE_SOURCE, + SourceSubmission { + generation: 1, + start_frame: at as u64, + sample_rate_hz: RATE, + planes: &[&plane, &plane], + 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_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); + run.allocations.0 += delta.allocations; + run.allocations.1 += delta.reallocations; + run.allocations.2 += delta.deallocations; + if report.swap == SwapOutcome::Applied { + run.adopted = Some(block); + } + run.blocks + .push(samples.iter().map(|sample| sample.to_bits()).collect()); + } + drop(handles); + drop(successor_gate); + run +} + +/// #1711 G1 on host-core, at both bank widths. A live level move (`S` to `-6` dB over its own +/// 4,000 samples, from block 4) on the riding fader, heard 1, 12, 24, 26 and 30 blocks (3,872, +/// 2,464, 928, 672 and 160 samples left; `J` = 960), then a table-only rebuild: the same ride +/// split in two at 10,000, or a change only in its last 2,000 samples. The move is a VCA move +/// (the committed model's VCA moves) or a hand move (the ride's stored offset moves, R17-1). +/// Every block, before and after the swap, is bit-equal to the uninterrupted engine's, the +/// render is allocation-free, the successor is adopted at the swap block and the strip carries. +/// +/// Test value: red if the adoption rule compares the curve with the stage's fader gain rather +/// than its ramp's target (#1711 F0: the held grid events only remember a gain inside the move's +/// ramp, so the fader gain follows the ride and every heard move lands at the swap), or if it +/// compares at the block's first sample rather than at the ramp's end. +#[cfg(feature = "test-support")] +#[test] +fn a_level_move_in_flight_across_a_table_only_rebuild_keeps_its_bits() { + const AFTER: usize = 44; + let changes = [ + ("the ride split at 10,000", (10_000, -12.0)), + ( + "a change in the last 2,000 samples", + (RIDE_FRAMES - 2_000, -6.0), + ), + ]; + // (label, the stored hand offset after the move, the VCA before and after it) + let moves = [ + ("a VCA move", 0.0, Some((0.0_f32, -6.0_f32))), + ("a hand move", -6.0, None), + ]; + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + for (what, hand, vca) in moves { + let a = level_move_model(None, 0.0, vca.map(|vca| vca.0)); + let committed = level_move_model(None, hand, vca.map(|vca| vca.1)); + for heard in [1, 12, 24, 26, 30] { + let swap = MOVE_BLOCK + heard; + let blocks = swap + AFTER; + let reference = level_move_run(&a, None, backend, blocks); + assert_eq!(reference.allocations, (0, 0, 0)); + for (change, split) in changes { + let label = format!("{backend:?}, {what}, heard {heard}, {change}"); + let next = level_move_model(Some(split), hand, vca.map(|vca| vca.1)); + let run = level_move_run(&a, Some((&next, &committed, swap)), backend, blocks); + assert_eq!(run.adopted, Some(swap), "{label}: adopted at the swap"); + assert!(run.restarted.is_empty(), "{label}: the strip carries"); + assert_eq!(run.allocations, (0, 0, 0), "{label}: allocation-free"); + assert_ne!( + reference.blocks[swap - 1], + level_move_run(&committed, None, backend, swap).blocks[swap - 1], + "{label}: the move is still heard before the swap" + ); + let first = run + .blocks + .iter() + .zip(&reference.blocks) + .position(|(run, reference)| run != reference); + assert_eq!(first, None, "{label}: first block that differs"); + } + } + } + } +} + #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] mod canonical_preparation { use super::*; From d3f6db642a0f89e9911b2703c6c42299351d3695 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 12:54:36 +0000 Subject: [PATCH 05/36] Add the graph types for automated send rows (#1654 T2) The seven-word route flags word gains plan-fixed bits 6-11 (automated gain, own mute, matrix ll/lr/rl/rr); a follow-route source accepts a route with only route-row cells; per-row jump lengths (source mute, SendGain, SendMatrix) replace the mute-only jump rule; graph_compiler::route_gain_law is the one dB law (route_transform calls it with offset -0.0, bit for bit). Types only: no render behaviour change. Realtime policy 168 -> 170. Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 44 +++ crates/graph-compiler/src/ids.rs | 110 +++++- crates/graph-compiler/src/lib.rs | 2 +- crates/graph/src/lib.rs | 328 +++++++++++++++--- crates/graph/src/runtime.rs | 30 +- crates/host-core/src/prepare.rs | 38 +- 6 files changed, 475 insertions(+), 77 deletions(-) diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 27233fc35..09f771076 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -435,3 +435,47 @@ is now step 3 `Automation` (rebuild) in the classifier; a hand move on an automa diffs as a route row 1 change whose value is the offset, and an automated matrix row's hand edit is a static-value change the classifier does not yet commit `model_only` (not checked here): the route lowering (offset record, matrix `model_only`) is host-core's work. + +## Decision record: T2 (batch 19) + +Graph types only; no render behaviour changes and no six-word route code, byte or bit moves. + +- **Flags word.** `RouteFollowFlags` gains `automated_gain` (bit 6), `automated_mute` (bit 7, the + route's own row 2) and `automated_matrix[ll, lr, rl, rr]` (bits 8-11), plan-fixed like bits 4-5; + `DEFINED = (1 << 12) - 1`. `from_word` and `FollowRouteControlRecord::{new, from_words}` accept and + round-trip them; bits 0-5 are unchanged. `route_rows()` gives bits 6-11 in row order. When bit 6 is + set, word 0 is the hand offset in dB (the record checks only finiteness, so it fits). `gate` is + unchanged (the route mute curve's OR is T3's composition). +- **Source.** `GraphFollowRouteSource::new(program, cells, route_cells, arrival, jumps, gain_law, + flags)`: `route_cells: [Option; GRAPH_ROUTE_ROWS]` (rows 1-6, `GRAPH_ROUTE_ROWS = 6`). It + accepts a route with no source mute cell when a route row cell is present, and refuses a route cell + outside the program and `flags.route_rows()` that disagree with `route_cells`. Host-core passes + `[None; 6]` and flags bits 6-11 clear until T3 gives the plan route cells. +- **Jump rule.** `GraphMuteJumpLength` is replaced by `GraphRouteJumpRule = fn(GraphJumpLengths) -> + GraphRouteJumpLengths { mute, gain, matrix }`, with `GraphRouteCursorRow { SourceMute, Gain, Mute, + Matrix }` and `GraphRouteJumpLengths::for_row`. Host-core's `follow_mute_jump` became + `follow_route_jumps` (`LiveRamps::for_row` of `Mute`, `SendGain`, `SendMatrix`, one realtime-policy + region as before). Departure: rather than a second fn pointer beside the mute one, the route op's + two reads (`FollowCursors::block`, `finish_six_word_carry`) now take + `for_row(SourceMute)` of the one rule, the same value as before; the field is renamed `jumps`. +- **dB law.** `graph_compiler::route_gain_law(curve_db, offset_db) = db_to_gain_f32(clamp(f64(curve) + + f64(offset), -144, 24) as f32)` (bounds `session::ROUTE_GAIN_DB_{MINIMUM,MAXIMUM}`), in its own + realtime-policy region in `ids.rs`, re-exported; `route_transform` calls it with offset `-0.0` (the + exact `f64` additive identity, so the static gain is `db_to_gain_f32(gain_db)` bit for bit). Graph + holds it as `GraphRouteGainLaw`; `check-graph-policy.sh` is unchanged and passes. +- **Unused until T3** (`#[expect(dead_code)]`): `GraphFollowRouteSource::route_cells` and + `GraphFollowRouteSource::gain_law`. The pub types (`GraphRouteCursorRow`, `GraphRouteJumpLengths::for_row` + for the gain and matrix rows, `RouteFollowFlags::route_rows`) need no expect. + +Tests: `graph` `follow_route_tests::{the_flags_word_round_trips_its_twelve_bits_and_refuses_every_other_bit, +the_record_round_trips_its_seven_words_and_refuses_out_of_bounds_words, +the_source_refuses_no_lane_a_missing_cell_and_disagreeing_flags, each_cursor_row_takes_its_own_jump_length}`; +`graph-compiler` `ids::gain_law_tests::{a_zero_offset_gives_the_static_gain_bits, +the_law_sums_in_f64_and_clamps_to_the_route_domain}` (flat `v` with offset `+0.0` and `-0.0` gives +`route_values(v)`'s gain bits and `db_to_gain_f32(v)`'s, over the route domain, both zeros included). + +Sizes (x86-64, measured): `RouteFollowFlags` 6 -> 12 bytes, `FollowRouteControlRecord` 32 -> 36, +`GraphFollowRouteSource` 48 -> 112, `LIVE_FOLLOW_ROUTE_OWNER_BYTES` (`size_of::()`, +the bind charge) 352 -> 360. Charges read `size_of`, so no pinned number moved. Realtime policy: 170 +marked regions in 34 files (168 before: `GraphRouteJumpLengths::for_row` and `route_gain_law` added; +host-core's jump-rule region replaced in place). diff --git a/crates/graph-compiler/src/ids.rs b/crates/graph-compiler/src/ids.rs index cbbdc0e9e..f6105c90e 100644 --- a/crates/graph-compiler/src/ids.rs +++ b/crates/graph-compiler/src/ids.rs @@ -291,7 +291,10 @@ pub(crate) fn sidechain_matches( /// the gain checks below cannot fail (every gain in `[-144, 24]` dB converts to a normal finite /// `f32`); they stay as defence for this function's own contract. pub(crate) fn route_transform(gain_db: f32, matrix: [f32; 4]) -> Option { - let gain = math::db_to_gain_f32(gain_db); + // A static gain is the law with no offset. `-0.0` is the exact additive identity of `f64` + // (`x + -0.0` is `x` for every `x`, `-0.0` included), and the clamp is the identity on the + // route domain `route_values` checks, so this is `db_to_gain_f32(gain_db)` bit for bit. + let gain = route_gain_law(gain_db, -0.0); let [ll, lr, rl, rr] = matrix; (gain_db.is_finite() && gain.is_finite() @@ -306,6 +309,31 @@ pub(crate) fn route_transform(gain_db: f32, matrix: [f32; 4]) -> Option f32 { + let sum = (f64::from(curve_db) + f64::from(offset_db)).clamp( + f64::from(session::ROUTE_GAIN_DB_MINIMUM), + f64::from(session::ROUTE_GAIN_DB_MAXIMUM), + ); + // The one rounding of the law: the clamped sum is in the `f32` route domain. + math::db_to_gain_f32(sum as f32) +} +// REALTIME_POLICY_END + /// The unfolded transform of a route whose values are in domain (issue #1237 D2), then /// `route_transform`'s checks. It backs [`route_coefficients`] and the compiler's lowering, so both /// refuse exactly the same values and the transform is derived once. The domain is the session @@ -431,3 +459,83 @@ pub(crate) fn diag(code: &'static str, path: &str) -> GraphDiagnostic { cycle_edge_paths: Vec::new(), } } + +#[cfg(test)] +mod gain_law_tests { + //! #1654 T2: the send gain law and the static route gain are one derivation. + use super::*; + + /// Gain values across the route domain: both zeros, the bounds, a negative and positive range + /// and values that are not exact in binary. + fn domain_values() -> Vec { + let mut values = vec![ + 0.0, + -0.0, + session::ROUTE_GAIN_DB_MINIMUM, + session::ROUTE_GAIN_DB_MAXIMUM, + -6.0, + 3.0, + 0.1, + -0.1, + f32::from_bits(1), + -f32::from_bits(1), + f32::MIN_POSITIVE, + ]; + let mut db = session::ROUTE_GAIN_DB_MINIMUM; + while db <= session::ROUTE_GAIN_DB_MAXIMUM { + values.push(db); + db += 0.37; + } + values + } + + /// A flat curve at `v` with an offset of `+0.0` or `-0.0` gives the bits of the static gain + /// `v` (`route_values`, the plan's lowering) and of `db_to_gain_f32(v)`, the conversion before + /// the law existed. + /// + /// Test value: red if the law rounds twice, adds the offset in `f32` with another result, or + /// lets the sign of a zero offset reach the gain, so a flat ride would not render the static + /// value bit for bit (gate 1). + #[test] + fn a_zero_offset_gives_the_static_gain_bits() { + let matrix = [1.0, 0.0, 0.0, 1.0]; + for value in domain_values() { + let fixed = route_values(value, matrix).expect("in domain").gain; + assert_eq!( + fixed.to_bits(), + math::db_to_gain_f32(value).to_bits(), + "static {value}" + ); + for offset in [0.0, -0.0] { + assert_eq!( + route_gain_law(value, offset).to_bits(), + fixed.to_bits(), + "curve {value}, offset {offset:?}" + ); + } + } + } + + /// The sum is taken in `f64`, rounded once and clamped to the route domain; a NaN stays NaN. + /// + /// Test value: red if the clamp is lost (a ride plus a hand offset past 24 dB would exceed the + /// route domain) or if the sum is rounded before it is clamped. + #[test] + fn the_law_sums_in_f64_and_clamps_to_the_route_domain() { + let top = math::db_to_gain_f32(session::ROUTE_GAIN_DB_MAXIMUM); + let bottom = math::db_to_gain_f32(session::ROUTE_GAIN_DB_MINIMUM); + assert_eq!(route_gain_law(20.0, 10.0).to_bits(), top.to_bits()); + assert_eq!(route_gain_law(-140.0, -10.0).to_bits(), bottom.to_bits()); + assert_eq!( + route_gain_law(-6.0, 3.0).to_bits(), + math::db_to_gain_f32(-3.0).to_bits() + ); + let (curve, offset) = (0.1_f32, 0.2_f32); + let once = (f64::from(curve) + f64::from(offset)) as f32; + assert_eq!( + route_gain_law(curve, offset).to_bits(), + math::db_to_gain_f32(once).to_bits() + ); + assert!(route_gain_law(f32::NAN, 0.0).is_nan()); + } +} diff --git a/crates/graph-compiler/src/lib.rs b/crates/graph-compiler/src/lib.rs index 705929f01..604bdfac4 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, route_values}; +pub use ids::{RouteValueError, route_coefficients, route_gain_law, route_values}; #[cfg(test)] mod tests { diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index cfad04414..e9023aed9 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1135,13 +1135,18 @@ impl GraphRouteControlProducer { /// 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. +/// The flags word of a following route's cell (#1652 D1, batch 18 layout; #1654 T2): 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]`, bit 6 `automated_gain`, bit 7 `automated_mute`, bits 8 to 11 +/// `automated_matrix[ll, lr, rl, rr]`. 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. +/// whether that source lane has a stored mute curve. Bits 6 to 11 are fixed by the plan too: whether +/// the route's own row 1 (`gain_db`), row 2 (`mute`) and rows 3 to 6 (the matrix) have a stored +/// curve (#1654 D2). When `automated_gain` is set, the cell's `gain` word holds the hand offset in +/// dB, not a linear gain (#1654, batch 10 amendment). #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct RouteFollowFlags { /// The route's own mute: it silences the route whatever the source does. @@ -1152,6 +1157,12 @@ pub struct RouteFollowFlags { pub terms: [bool; 2], /// Per source lane `[L, R]`: whether the plan automates the lane's mute. pub automated: [bool; 2], + /// Whether the plan automates the route's own `gain_db` (row 1). + pub automated_gain: bool, + /// Whether the plan automates the route's own `mute` (row 2). + pub automated_mute: bool, + /// Per matrix entry `[ll, lr, rl, rr]` (rows 3 to 6): whether the plan automates it. + pub automated_matrix: [bool; 4], } impl RouteFollowFlags { @@ -1159,8 +1170,11 @@ impl RouteFollowFlags { const FOLLOWS_MUTE: u32 = 1 << 1; const TERMS: [u32; 2] = [1 << 2, 1 << 3]; const AUTOMATED: [u32; 2] = [1 << 4, 1 << 5]; + const AUTOMATED_GAIN: u32 = 1 << 6; + const AUTOMATED_MUTE: u32 = 1 << 7; + const AUTOMATED_MATRIX: [u32; 4] = [1 << 8, 1 << 9, 1 << 10, 1 << 11]; /// Every bit the layout defines; any other bit set makes a word no record wrote. - const DEFINED: u32 = (1 << 6) - 1; + const DEFINED: u32 = (1 << 12) - 1; /// The flags word. #[must_use] @@ -1174,10 +1188,16 @@ impl RouteFollowFlags { | bit(self.terms[1], Self::TERMS[1]) | bit(self.automated[0], Self::AUTOMATED[0]) | bit(self.automated[1], Self::AUTOMATED[1]) + | bit(self.automated_gain, Self::AUTOMATED_GAIN) + | bit(self.automated_mute, Self::AUTOMATED_MUTE) + | bit(self.automated_matrix[0], Self::AUTOMATED_MATRIX[0]) + | bit(self.automated_matrix[1], Self::AUTOMATED_MATRIX[1]) + | bit(self.automated_matrix[2], Self::AUTOMATED_MATRIX[2]) + | bit(self.automated_matrix[3], Self::AUTOMATED_MATRIX[3]) } // REALTIME_POLICY_BEGIN - /// The flags a word holds, or `None` when a bit above bit 5 is set. + /// The flags a word holds, or `None` when a bit above bit 11 is set. #[must_use] pub const fn from_word(word: u32) -> Option { if word & !Self::DEFINED != 0 { @@ -1191,9 +1211,25 @@ impl RouteFollowFlags { word & Self::AUTOMATED[0] != 0, word & Self::AUTOMATED[1] != 0, ], + automated_gain: word & Self::AUTOMATED_GAIN != 0, + automated_mute: word & Self::AUTOMATED_MUTE != 0, + automated_matrix: [ + word & Self::AUTOMATED_MATRIX[0] != 0, + word & Self::AUTOMATED_MATRIX[1] != 0, + word & Self::AUTOMATED_MATRIX[2] != 0, + word & Self::AUTOMATED_MATRIX[3] != 0, + ], }) } + /// Per route row 1 to 6 (`gain_db`, `mute`, `ll`, `lr`, `rl`, `rr`), whether the plan + /// automates it: the order of [`GraphFollowRouteSource`]'s route cells. + #[must_use] + pub const fn route_rows(self) -> [bool; GRAPH_ROUTE_ROWS] { + let [ll, lr, rl, rr] = self.automated_matrix; + [self.automated_gain, self.automated_mute, ll, lr, rl, rr] + } + /// 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`] @@ -1248,7 +1284,9 @@ impl FollowRouteControlRecord { length, }) } - /// The open transform: the route's linear gain and its 2x2, ungated. + /// The open transform: the route's linear gain and its 2x2, ungated. When the flags' + /// `automated_gain` is set, `gain` is the hand offset in dB instead (#1654), and a matrix entry + /// whose row the plan automates is the static fallback, which render does not hear. #[must_use] pub const fn transform(&self) -> RouteTransform { self.transform @@ -1286,7 +1324,7 @@ impl FollowRouteControlRecord { } // REALTIME_POLICY_BEGIN - /// The record a cell's words hold: `None` for words no record wrote (a flags bit above bit 5, + /// The record a cell's words hold: `None` for words no record wrote (a flags bit above bit 11, /// 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 { @@ -1354,15 +1392,69 @@ 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; +/// The route rows 1 to 6 a following route can automate (#1654 D1): `gain_db`, `mute`, `ll`, +/// `lr`, `rl`, `rr`, in that order. Graph spells no row ID; the order is the one of +/// [`RouteFollowFlags::route_rows`] and of [`GraphFollowRouteSource`]'s route cells. +pub const GRAPH_ROUTE_ROWS: usize = 6; -/// 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. +/// Which jump length one cursor of a following route takes (#1654 D1, batch 19 spec check): the +/// mute rule for a source lane's mute and for the route's own mute, `SendGain` for the route's +/// gain and `SendMatrix` for a matrix entry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GraphRouteCursorRow { + /// A source lane's stored mute (#1652). + SourceMute, + /// The route's own `gain_db` (row 1). + Gain, + /// The route's own `mute` (row 2). + Mute, + /// One of the route's matrix entries (rows 3 to 6). + Matrix, +} + +/// The jump lengths, in samples, of a following route's cursor rows for one block (#1654 D1), +/// resolved from the block's three session lengths by [`GraphRouteJumpRule`]. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct GraphRouteJumpLengths { + /// The mute row's length: a source lane's mute and the route's own mute. + pub mute: u32, + /// The `SendGain` row's length. + pub gain: u32, + /// The `SendMatrix` row's length. + pub matrix: u32, +} + +impl GraphRouteJumpLengths { + // REALTIME_POLICY_BEGIN + /// The jump length of a cursor of `row`. + #[must_use] + pub const fn for_row(self, row: GraphRouteCursorRow) -> u32 { + match row { + GraphRouteCursorRow::SourceMute | GraphRouteCursorRow::Mute => self.mute, + GraphRouteCursorRow::Gain => self.gain, + GraphRouteCursorRow::Matrix => self.matrix, + } + } + // REALTIME_POLICY_END +} + +/// The jump lengths of a following route's cursor rows, resolved from the block's three session +/// lengths (#1652b, #1654 D1). Host-core hands in the row rules (`controls::LiveRamps::from_lengths` +/// and `for_row` of `LiveRampRow::Mute`, `SendGain` and `SendMatrix`, the rules the strip's stages +/// use), so graph never spells a row and the strip ramp and the send ramp cannot differ in length. +pub type GraphRouteJumpRule = fn(GraphJumpLengths) -> GraphRouteJumpLengths; + +/// The route's dB law for an automated send gain (#1654, batch 10 amendment): the linear gain of +/// the curve value `curve_db` with the hand offset `offset_db`, that is +/// `db_to_gain_f32(clamp(f64(curve) + f64(offset), -144, 24) as f32)`. Graph does not link +/// `math`; host-core hands in `graph_compiler::route_gain_law`, the function the plan's own +/// `route_transform` calls for a static gain, so the two are one derivation. +pub type GraphRouteGainLaw = fn(curve_db: f32, offset_db: f32) -> f32; + +/// What one following route reads of its stored automation (#1652 D2, #1652b, #1654 D2): the +/// plan's immutable automation program, the source lanes' mute cells in it, the route's own row +/// cells in it, the arrival `a(tap)` of the route's tap, the cursor rows' jump-length rule, the +/// route's gain law 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 @@ -1373,39 +1465,61 @@ pub type GraphMuteJumpLength = fn(GraphJumpLengths) -> u32; pub struct GraphFollowRouteSource { pub(crate) program: std::sync::Arc, pub(crate) cells: [Option; 2], + #[expect( + dead_code, + reason = "#1654 T2: the route op's cursor table (T3) reads the route's own row cells" + )] + pub(crate) route_cells: [Option; GRAPH_ROUTE_ROWS], pub(crate) arrival: u64, - pub(crate) mute_jump: GraphMuteJumpLength, + pub(crate) jumps: GraphRouteJumpRule, + #[expect( + dead_code, + reason = "#1654 T2: the route op's gain composition (T3) calls the route's gain law" + )] + pub(crate) gain_law: GraphRouteGainLaw, 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. + /// `program`, `None` for a lane the plan does not automate; `route_cells[row]` is the route's + /// own row cell in `program` (order [`GRAPH_ROUTE_ROWS`]), `None` for a row the plan does not + /// automate; `arrival` is `a(tap)`; `jumps` resolves the cursor rows' jump lengths; `gain_law` + /// is the route's dB law; `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). + /// `None` when no source lane and no route row is automated (the route keeps the six-word + /// cell), when a cell is not in `program`, or when `flags.automated` disagrees with `cells` or + /// `flags.route_rows()` with `route_cells` (the plan fixes which lanes and rows are automated, + /// D1). #[must_use] pub fn new( program: std::sync::Arc, cells: [Option; 2], + route_cells: [Option; GRAPH_ROUTE_ROWS], arrival: u64, - mute_jump: GraphMuteJumpLength, + jumps: GraphRouteJumpRule, + gain_law: GraphRouteGainLaw, flags: RouteFollowFlags, ) -> Option { let automated = cells.map(|cell| cell.is_some()); + let rows = route_cells.map(|cell| cell.is_some()); let in_program = cells .iter() + .chain(&route_cells) .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, - }) + let any = automated.contains(&true) || rows.contains(&true); + (any && in_program && flags.automated == automated && flags.route_rows() == rows).then_some( + Self { + program, + cells, + route_cells, + arrival, + jumps, + gain_law, + flags, + }, + ) } /// `a(tap)`, the arrival the route's events are timed at. @@ -1429,7 +1543,8 @@ pub struct GraphFollowRouteControlBinding { 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. + /// The source strip's mute cells, the route's own row cells, `a(tap)`, the jump rule, the + /// gain law and the seeded flags. pub source: GraphFollowRouteSource, } @@ -10470,6 +10585,14 @@ mod follow_route_tests { follows_mute: word & 2 != 0, terms: [word & 4 != 0, word & 8 != 0], automated: [word & 16 != 0, word & 32 != 0], + automated_gain: word & 64 != 0, + automated_mute: word & 128 != 0, + automated_matrix: [ + word & 256 != 0, + word & 512 != 0, + word & 1024 != 0, + word & 2048 != 0, + ], } } @@ -10487,9 +10610,19 @@ 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 + /// The cursor rows' jump lengths of the tests: the session's `mute_ms`, `fader_ms` and + /// `pan_ms` lengths. + pub(crate) fn route_jumps(lengths: GraphJumpLengths) -> GraphRouteJumpLengths { + GraphRouteJumpLengths { + mute: lengths.mute_samples, + gain: lengths.fader_samples, + matrix: lengths.pan_samples, + } + } + + /// The gain law of the tests: the plain sum, which no test of this tranche renders. + pub(crate) fn sum_law(curve_db: f32, offset_db: f32) -> f32 { + curve_db + offset_db } /// A program of one mute-shaped cell per given lane (steps, completion `End`, block jump @@ -10524,8 +10657,16 @@ mod follow_route_tests { automated: cells.map(|cell| cell.is_some()), ..RouteFollowFlags::default() }; - GraphFollowRouteSource::new(program, cells, arrival, mute_length, flags) - .expect("a valid source") + GraphFollowRouteSource::new( + program, + cells, + [None; GRAPH_ROUTE_ROWS], + arrival, + route_jumps, + sum_law, + flags, + ) + .expect("a valid source") } /// A step curve: `values[i]` from `times[i]` on (the first value from the start). @@ -10544,11 +10685,13 @@ mod follow_route_tests { .collect() } - /// A source refuses no automated lane, a cell outside its program and `automated` flags that - /// disagree with its cells. + /// A source refuses no automated lane or row, a cell outside its program and `automated` + /// flags that disagree with its cells; it accepts a route whose only automation is its own + /// rows (#1654 D2), and refuses route-row flags that disagree with its route 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 value: red if a route with no automation takes the seven-word render state, if a send + /// with only route-row automation is refused it, 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); @@ -10556,22 +10699,32 @@ mod follow_route_tests { assert_eq!(source.arrival(), 7); let program = std::sync::Arc::clone(&source.program); let flags = source.flags(); - let new = |cells, flags| { + let new = |cells, route_cells, flags| { GraphFollowRouteSource::new( std::sync::Arc::clone(&program), cells, + route_cells, 0, - mute_length, + route_jumps, + sum_law, 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"); + let no_rows = [None; GRAPH_ROUTE_ROWS]; + assert!(new([Some(0), None], no_rows, flags)); + assert!( + !new([None, None], no_rows, RouteFollowFlags::default()), + "no lane" + ); + assert!( + !new([Some(1), None], no_rows, flags), + "a cell outside the program" + ); assert!( !new( [Some(0), None], + no_rows, RouteFollowFlags { automated: [true, true], ..flags @@ -10579,21 +10732,78 @@ mod follow_route_tests { ), "flags that disagree" ); + + // Route-row automation only: the route's gain and its `rl` entry over the one cell. + let gain_and_rl = [Some(0), None, None, None, Some(0), None]; + let rows_only = RouteFollowFlags { + automated_gain: true, + automated_matrix: [false, false, true, false], + ..RouteFollowFlags::default() + }; + assert!(new([None, None], gain_and_rl, rows_only), "route rows only"); + assert!( + !new( + [None, None], + [Some(0), None, None, None, Some(1), None], + rows_only + ), + "a route cell outside the program" + ); + for disagreeing in [ + RouteFollowFlags { + automated_mute: true, + ..rows_only + }, + RouteFollowFlags { + automated_matrix: [false, false, false, true], + ..rows_only + }, + RouteFollowFlags { + automated_gain: false, + ..rows_only + }, + ] { + assert!( + !new([None, None], gain_and_rl, disagreeing), + "route-row flags that disagree: {disagreeing:?}" + ); + } + } + + /// The cursor rows' jump lengths: the source mute and the route mute take the mute length, + /// the gain the `SendGain` length and a matrix entry the `SendMatrix` length. + /// + /// Test value: red if a route cursor would jump over another row's length. + #[test] + fn each_cursor_row_takes_its_own_jump_length() { + let lengths = GraphRouteJumpLengths { + mute: 3, + gain: 5, + matrix: 7, + }; + assert_eq!(lengths.for_row(GraphRouteCursorRow::SourceMute), 3); + assert_eq!(lengths.for_row(GraphRouteCursorRow::Mute), 3); + assert_eq!(lengths.for_row(GraphRouteCursorRow::Gain), 5); + assert_eq!(lengths.for_row(GraphRouteCursorRow::Matrix), 7); } - /// 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. + /// The layout: bit 0 mute, 1 follows_mute, 2-3 terms L/R, 4-5 automated L/R, 6 + /// automated_gain, 7 automated_mute, 8-11 automated_matrix ll/lr/rl/rr (#1654 T2). Every one of + /// the 4096 defined words decodes to its fields and encodes back to itself, and its route rows + /// read bits 6 to 11 in row order; a word with any bit from 12 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 { + fn the_flags_word_round_trips_its_twelve_bits_and_refuses_every_other_bit() { + for word in 0..4_096_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 { + let rows: [bool; GRAPH_ROUTE_ROWS] = + core::array::from_fn(|row| (word >> (6 + row)) & 1 != 0); + assert_eq!(flags.route_rows(), rows, "word {word:#b}"); + for bit in 12..32 { assert_eq!( RouteFollowFlags::from_word(word | 1 << bit), None, @@ -10657,6 +10867,20 @@ mod follow_route_tests { assert_eq!(record.flags(), flags); assert_eq!(record.length(), 480); + // #1654 T2: the route-row bits round trip, and an automated gain's offset word (dB) fits. + let rows = flags_of(0b1010_1110_0110); + let offset = RouteTransform { + gain: -6.0, + ..transform() + }; + let record = FollowRouteControlRecord::new(offset, rows, 64).expect("a record"); + assert_eq!(record.to_words()[0], (-6.0_f32).to_bits()); + assert_eq!(record.to_words()[5], 0b1010_1110_0110); + assert_eq!( + FollowRouteControlRecord::from_words(record.to_words()), + Some(record) + ); + let bound = FollowRouteControlRecord::new(transform(), flags, ROUTE_RAMP_LENGTH_MAXIMUM) .expect("the bound is in bounds"); assert_eq!( @@ -10678,7 +10902,7 @@ mod follow_route_tests { long[6] = ROUTE_RAMP_LENGTH_MAXIMUM + 1; assert_eq!(FollowRouteControlRecord::from_words(long), None); let mut undefined = words; - undefined[5] |= 1 << 6; + undefined[5] |= 1 << 12; assert_eq!(FollowRouteControlRecord::from_words(undefined), None); } diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index bbf569697..9a03531b3 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -1438,7 +1438,7 @@ impl ColumnRamps { /// 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. +/// state the route has heard, `a(tap)` and the cursor rows' jump-length rule. struct FollowCursors { program: std::sync::Arc, /// `[L, R]`; `None` for a lane the plan does not automate. @@ -1449,7 +1449,7 @@ struct FollowCursors { curve: [bool; 2], /// `a(tap)`. arrival: u64, - mute_jump: crate::GraphMuteJumpLength, + jumps: crate::GraphRouteJumpRule, /// How the route's next block begins: fresh, carried or running. start: FollowStart, } @@ -1514,7 +1514,7 @@ impl LiveFollowRoute { states, curve, arrival: source.arrival, - mute_jump: source.mute_jump, + jumps: source.jumps, start: FollowStart::Fresh, }; let (target, mute) = follow.compose(&transform, source.flags, curve); @@ -1576,7 +1576,10 @@ impl FollowCursors { let Some(lengths) = time.jump_lengths() else { return (FollowBlock::IDLE, start, placed); }; - let jump = core::cmp::min((self.mute_jump)(lengths), crate::ROUTE_RAMP_LENGTH_MAXIMUM); + let jump = core::cmp::min( + (self.jumps)(lengths).for_row(crate::GraphRouteCursorRow::SourceMute), + crate::ROUTE_RAMP_LENGTH_MAXIMUM, + ); let idle = FollowBlock { spans: [None; 2], jump, @@ -5713,14 +5716,14 @@ impl RouteCarryCheck { /// 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, 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 +/// cursor rows' 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, + jumps: crate::GraphRouteJumpRule, } /// One six-word successor's comparison at its first block (#1652 D3, the curve removed), in the @@ -5737,7 +5740,10 @@ fn finish_six_word_carry( lengths: Option, ) { let length = lengths.map_or(0, |lengths| { - core::cmp::min((check.mute_jump)(lengths), crate::ROUTE_RAMP_LENGTH_MAXIMUM) + core::cmp::min( + (check.jumps)(lengths).for_row(crate::GraphRouteCursorRow::SourceMute), + crate::ROUTE_RAMP_LENGTH_MAXIMUM, + ) }); if let Some((target, mute)) = live.apply_pending_heard(snapshot, check.zeroed) { live.retarget_carried(target, mute, length); @@ -5786,7 +5792,7 @@ fn carry_route( ( from.ramps, from.mute, - Some((from.follow.curve, zeroed, from.follow.mute_jump)), + Some((from.follow.curve, zeroed, from.follow.jumps)), ) } }; @@ -5801,7 +5807,7 @@ fn carry_route( let (prepared, prepared_mute) = (into.ramp.target, into.mute); let (ramp, position) = ramps.one_ramp(); into.adopt_state(ramp, position, mute); - let Some((_, zeroed, mute_jump)) = follow else { + let Some((_, zeroed, jumps)) = follow else { return false; }; if prepared.map(f32::to_bits) == ramp.target.map(f32::to_bits) && zeroed == [false; 2] { @@ -5818,7 +5824,7 @@ fn carry_route( prepared, mute: prepared_mute, zeroed, - mute_jump, + jumps, }); true } @@ -6398,8 +6404,8 @@ impl crate::runtime::Runtime { /// 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. + /// length (the jump rule's source-mute length 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: &[RouteCarryStep], diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 5f523015a..a890c5a39 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -2893,6 +2893,10 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( follows_mute: route.follows_mute, terms: effective.strips[source].mute, automated: cells.map(|cell| cell.is_some()), + // #1654 T2: the plan gives a route no row cell yet, so no route row is automated. + automated_gain: false, + automated_mute: false, + automated_matrix: [false; 4], }; follow_cells.push((route.id.as_str(), (program, cells, flags))); } @@ -2956,8 +2960,10 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( graph::GraphFollowRouteSource::new( program, cells, + [None; graph::GRAPH_ROUTE_ROWS], graph::floored_input_arrival(&arrivals, &node), - follow_mute_jump, + follow_route_jumps, + graph_compiler::route_gain_law, flags, ) }) @@ -4383,6 +4389,10 @@ pub(crate) fn follow_route_record( follows_mute: route.follows_mute, terms, automated, + // #1654 T2: the plan gives a route no row cell yet, so no route row is automated. + automated_gain: false, + automated_mute: false, + automated_matrix: [false; 4], }; graph::FollowRouteControlRecord::new(transform, flags, length).ok_or(LiveRebuild::Domain) } @@ -5094,19 +5104,25 @@ pub(crate) fn following_route_lanes( (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 -/// 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( +/// The jump lengths of a following route's cursor rows (#1652 D2, #1652b, #1654 D1): the row +/// rules over the block's session lengths, the rules the strip's own stages resolve +/// ([`LiveRamps::for_row`] of [`LiveRampRow::Mute`] for a source lane's and the route's own mute, +/// of [`LiveRampRow::SendGain`] for the gain and of [`LiveRampRow::SendMatrix`] for a matrix +/// entry), so the strip ramp and the send ramp have one length. Called on render (through the +/// route's `GraphRouteJumpRule`): `const fn` calls, struct builds and `match` reads, +/// allocation-free and panic-free. +// REALTIME_POLICY_BEGIN: the route rows' jump rule a following route calls once per block. +fn follow_route_jumps(lengths: GraphJumpLengths) -> graph::GraphRouteJumpLengths { + let ramps = LiveRamps::from_lengths( lengths.fader_samples, lengths.mute_samples, lengths.pan_samples, - ) - .for_row(LiveRampRow::Mute) + ); + graph::GraphRouteJumpLengths { + mute: ramps.for_row(LiveRampRow::Mute), + gain: ramps.for_row(LiveRampRow::SendGain), + matrix: ramps.for_row(LiveRampRow::SendMatrix), + } } // REALTIME_POLICY_END From 44c96793bb64da2242d85cb4cc25368bea8e4316 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 13:05:18 +0000 Subject: [PATCH 06/36] Defer a moved target's jump to a live move's end at a Reposition (#1711 F2) At a Reposition adoption, a free stage's live ramp now runs to its end as the uninterrupted engine runs it; a moved target defers the cell's J jump to the first free sample after the ramp (CellState::defer_jump), and a restartable cell holds its grid events until the deferral fires. A free live write and the hold releases clear the deferral. The stretched landing (long_landing, max(J, remaining)) is deleted. Gates G2 and R19-1 on the C ABI, in host-core at Simd8 and Simd4, and as a both-host parity case. Co-Authored-By: Claude Opus 5.5 --- ...ves-own-length-across-a-reposition-swap.md | 62 ++++++ crates/automation/src/events.rs | 155 +++++++++++++- crates/builtins-compiler/src/lib.rs | 168 +++++++-------- crates/capi/src/runtime/live_tests.rs | 169 +++++++++++++++ .../tests/stored_fader_automation.rs | 202 +++++++++++++++++- hosts/host-web/tests/control_parity.rs | 69 ++++++ 6 files changed, 719 insertions(+), 106 deletions(-) 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 2de53fb07..663e3b079 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 @@ -328,3 +328,65 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af same; tests of builtins, builtins-compiler, automation (all features), host-core `stored_fader_automation` (31), `stored_mute_automation` (13), `successor_swap` (140), `session_seek`, `browser_successor`, capi lib (209), host-web `control_parity` (30): all green. + +## Decision record: F2 (batch 19) + +- **D1 shipped (A2).** `crates/automation/src/events.rs`: new `CellState::defer_jump(at)`, + `clear_deferred_jump()` and `deferred_jump()` (small `pub` methods on the existing `deferred` + field; no new state, 32 B). One rule change: a restartable cell holds its grid events while a + deferral is pending (`time < deferred`); a `no_restart` cell is unchanged. The existing deferred + path in `next_in_run` fires the jump (a `Jump` over the block's `J`, change only, or always with + a held event outstanding). +- `crates/builtins-compiler/src/lib.rs`: `settle_adoption` returns `bool` (land at the block's + first sample over `J`), not a length. On a `Reposition` of a free stage with a ramp in flight + and no step of the cell's own at the block's first sample, it holds the ramp again, compares the + curve at the ramp's end with the stage's ramp target, and, if it differs, calls + `defer_jump(node_time_at(span, remaining))` (no landing at the swap; R19-1). The landing at the + block's first sample over `J` stays for no ramp in flight, a held stage, D3 and `Compare`. + Deleted: `long_landing`, its one-sample landing arm, `length.max(remaining)` and the `jump` field + of `AdoptionBlock`. `clear_deferred_jump()` is called on `offsets_jump`'s free path, at the + R17-10 release, and at the two `release_hold_by` sites (through a new `release_lane_hold`). + R19-2: the decisions are the `CellState` methods above, for K1 to reuse; `compose.rs` is not + written here. +- **The B17-R14 flag stays** (F4 deletes it), so F2 asserts no `OUTCOME_EXACT`: the G2 and R19-1 + cases are inside the level-move window and still report the transition fallback. +- **G2 (A3).** C ABI (Simd8): `a_moved_target_jumps_at_a_live_moves_first_free_sample` + (`crates/capi/src/runtime/live_tests.rs`). Host-core (Simd8 and Simd4): + `a_moved_target_jumps_over_the_fader_length_at_the_moves_first_free_sample` + (`crates/host-core/tests/stored_fader_automation.rs`; `level_move_run` now takes a list of + `(block, record)` moves and writes a later one to the successor's cells). Flat ride -3 dB, move + -6 dB over 4,000 from block 4, heard 12 and 30, VCA and hand, rebuild to -5 dB to 6,000 then + -3 dB; reference: a fresh plan with -3 dB to 4,512, -5 dB to 6,000, then -3 dB, same move. Every + sample bit-equal, allocation-free, adopted, the strip carries. Case (c): a second move -9 dB over + 1,200 from block 35 (ends 5,679, after 4,512 + `J`): bit-equal to a fresh plan with the + rebuild's table given both moves from 5,679 on; host-core also asserts the lane still moves on + the move's last sample (the length). +- **R19-1 gate.** `a_moved_target_lets_a_live_move_longer_than_the_jump_end_when_it_was_due` (C + ABI and host-core, same names): heard 1 and 12 (3,872 and 2,464 left, both >= `J`), bit-equal + to the fresh plan with the jump at 4,512. +- **Both hosts.** `a_moved_target_defers_its_jump_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`): hand move heard 3 blocks, rebuild to -5 dB; same + verdicts, replies, watermark and bits over 60 blocks at the four rates. +- **Unit tests** (`events.rs`): `a_deferred_jump_fires_at_its_time_and_holds_the_grid_before_it`, + `a_cleared_deferral_fires_nothing_and_holds_no_grid`. +- **Mutations (each run once, restored).** Land at the swap instead of deferring (the old + behaviour): R19-1 red (C ABI and host-core, VCA move, heard 1) and G2 red (heard 12). No grid + hold while deferring: G2 and R19-1 red (host-core, heard 12 and heard 1). No clear in + `offsets_jump`: G2 case (c) red (host-core length assertion, VCA move, heard 12). Compare at + the block's first sample (`at = end` removed): G2 stays green (on a flat table both points read + -5 dB, as A0 found for the filed G2); G1 host-core is red (heard 1, the ride split at 10,000). + So `at = end` is held by G1, not by G2. +- **F1 note checked: no.** Two table-only rebuilds 5 blocks apart (riding ride, R1 split at + 10,000 to -6 dB, R2 to -9 dB) against a fresh plan prepared with R2's table: with the 500-sample + move (heard 2), bit-equal up to R2, then max 7.7e-5 (block 15, 4 blocks after R2); with no move, + max 1.7e-4 after R1 (block 9) and 1.4e-4 after R2. Cause: at the adoption the stage's grid-ramp + target (taken from the old table's values) and the new table's curve differ in `f32` rounding, + so the cell jumps over `J` (deferred to the grid ramp's end, or at the block's first sample), + where a fresh plan never jumps. Not fixed (outside D1); the D2 tranche's two-rebuild case needs + an outcome-only claim or a phase-2 decision on rounding-only differences. +- Gates run: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` on automation, + builtins, builtins-compiler, host-core, capi (and host-web `--all-targets`); `cargo check --locked + --all-targets` (no features) on the five; tests: automation, builtins-compiler (all features), + host-core `stored_fader_automation` (33), `stored_mute_automation` (13), `successor_swap` (140), + `session_seek`, `browser_successor`, capi lib (211), host-web `control_parity` (31): all green. + Realtime policy: 168 regions (unchanged). diff --git a/crates/automation/src/events.rs b/crates/automation/src/events.rs index 5bed33c9e..e35b571de 100644 --- a/crates/automation/src/events.rs +++ b/crates/automation/src/events.rs @@ -98,7 +98,8 @@ pub struct CellState { held: bool, /// The node time at which the ramp in flight completes, or [`IDLE`]. ramp_done: i64, - /// The node time at which a deferred jump fires (a `no_restart` cell), or [`IDLE`]. + /// The node time at which a deferred jump fires, or [`IDLE`]: a `no_restart` cell's jump that + /// waits for its lane, or a jump the caller deferred ([`Self::defer_jump`]). deferred: i64, } @@ -156,6 +157,41 @@ impl CellState { } } + /// Defers the cell's jump to node time `at` (#1711 D1): at the first sample at or after `at`, + /// the cell compares its curve with its current target, as at a discontinuity (a + /// [`EventKind::Jump`] over the jump length to the curve at the jump's completion, change + /// only, or always when a held event is outstanding). It replaces a deferral already pending. + /// + /// The caller defers the jump of a lane whose table changed while a ramp of its own runs (a + /// live move adopted at a plan swap) to that ramp's first free sample, so the ramp keeps its + /// own length. Until the deferral fires, every grid event of a restartable cell is held (the + /// stage remembers it and does not retarget), so no grid ramp cuts the running ramp short; a + /// `no_restart` cell holds them inside the ramp in flight as it always does. Cleared by + /// [`Self::clear_deferred_jump`], [`Self::reposition`] and a seek landing. Control-free and + /// allocation-free. + pub const fn defer_jump(&mut self, at: i64) { + self.deferred = at; + } + + /// Drops a pending deferred jump ([`Self::defer_jump`]), and with it the grid hold it keeps; + /// with none pending it does nothing. The caller clears it where the ramp the deferral waits + /// for is replaced (a newer live move keeps its own length) or ends early (a release of the + /// lane's hold), so the cell's grid moves the lane again from its next grid point. + pub const fn clear_deferred_jump(&mut self) { + self.deferred = IDLE; + } + + /// The node time of the pending deferred jump, if any ([`Self::defer_jump`], or a + /// `no_restart` cell's jump that waits for its lane). + #[must_use] + pub const fn deferred_jump(&self) -> Option { + if self.deferred == IDLE { + None + } else { + Some(self.deferred) + } + } + /// 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. @@ -393,7 +429,13 @@ impl<'a, 's> CellEvents<'a, 's> { let value = self.target_at(time, ramp); let done = self.completion(time, ramp); let in_flight = time < self.free_at(); - if in_flight && (self.program.no_restart || done < self.state.ramp_done) { + // A restartable cell holds its grid until a pending deferral fires (#1711 D1): a grid + // ramp that completes after the ramp in flight would otherwise restart it with the new + // curve and cut it short. A `no_restart` cell's deferral always lies at its lane's free + // time, so its rule is unchanged. + let deferring = + !self.program.no_restart && self.state.deferred != IDLE && time < self.state.deferred; + if (in_flight && (self.program.no_restart || done < self.state.ramp_done)) || deferring { let differs = !self.state.has_target || self.state.target != value.to_bits(); if !differs && !self.state.held { return None; @@ -558,6 +600,115 @@ mod tests { .expect("valid cell") } + /// A restartable cell riding linearly from 0 to -12 over 4,096 samples (a grid event at + /// every 64th sample moves it). + fn riding() -> crate::Program { + let segments = [Segment { + start: 0, + end: 4_096, + start_value: 0.0, + end_value: -12.0, + shape: Shape::Linear, + }]; + ProgramBuilder::build(&[CellDescription { + segments: &segments, + grid_ramp: 64, + grid: 64, + jump: JumpLength::Block, + completion: Completion::End, + no_restart: false, + }]) + .expect("valid cell") + } + + /// The `(offset, kind, held)` of every event of the block of 256 samples from node time 0. + fn block_events( + program: &crate::Program, + state: &mut CellState, + ) -> Vec<(u32, EventKind, bool)> { + let span = NodeSpan::new(0, None, 256).expect("span"); + CellEvents::new( + program.cell(0).expect("cell"), + state, + span, + SeekMode::Exact, + 480, + ) + .map(|event| (event.offset, event.kind, event.held)) + .collect() + } + + /// A cell seeded at its curve's start whose caller reported a ramp completing at node time + /// 200 (a live move), so its first free sample is 201. + fn in_a_move() -> CellState { + let mut state = CellState::new(); + state.seed_target(0.0); + state.hold_until(200); + state + } + + /// #1711 D1: a jump deferred to the first free sample of a ramp in flight fires there over the + /// jump length, and every grid event before it is held, also the one whose grid ramp would + /// complete after the ramp in flight (which restarts the ramp with no deferral). + /// + /// Red if the deferral is not fired on a restartable cell, or if the grid event at 192 (its + /// ramp completes at 255, after 200) is not held while the deferral is pending. + #[test] + fn a_deferred_jump_fires_at_its_time_and_holds_the_grid_before_it() { + let program = riding(); + let mut plain = in_a_move(); + let held = |offset| (offset, EventKind::Grid, true); + assert_eq!( + block_events(&program, &mut plain), + [held(0), held(64), held(128), (192, EventKind::Grid, false)], + "no deferral: the grid restarts the ramp at 192" + ); + let mut deferred = in_a_move(); + deferred.defer_jump(201); + assert_eq!(deferred.deferred_jump(), Some(201)); + assert_eq!( + block_events(&program, &mut deferred), + [ + held(0), + held(64), + held(128), + held(192), + (201, EventKind::Jump, false) + ] + ); + assert_eq!(deferred.deferred_jump(), None, "a fired deferral is spent"); + } + + /// #1711 D1: a cleared deferral fires nothing and holds no grid event: the cell's events are + /// those of a cell that never deferred; a deferral whose target the curve already has emits + /// nothing (change only), and `reposition` drops it. + /// + /// Red if `clear_deferred_jump` leaves the deferral or its grid hold in place (a held grid + /// would freeze the lane after an early release, #1651 U1 MAJOR-1). + #[test] + fn a_cleared_deferral_fires_nothing_and_holds_no_grid() { + let program = riding(); + let mut cleared = in_a_move(); + cleared.defer_jump(201); + cleared.clear_deferred_jump(); + assert_eq!(cleared.deferred_jump(), None); + assert_eq!( + block_events(&program, &mut cleared), + block_events(&program, &mut in_a_move()) + ); + + let flat = flat(-7.25); + let mut settled = CellState::new(); + settled.seed_target(-7.25); + settled.defer_jump(100); + assert_eq!(first_block(&flat, &mut settled), [], "change only"); + + let mut repositioned = in_a_move(); + repositioned.defer_jump(201); + repositioned.reposition(&program.cell(0).expect("cell"), 0); + assert_eq!(repositioned.deferred_jump(), None); + } + fn first_block(program: &crate::Program, state: &mut CellState) -> Vec<(u32, EventKind)> { let span = NodeSpan::new(0, None, 256).expect("span"); CellEvents::new( diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 9004598f8..b306e0a52 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -1346,6 +1346,9 @@ impl AutomatedFaderCell { // record takes this same path at its first block, with the carried obligation. self.obligation.held_through = false; self.state.seed_target(value); + // A newer live move keeps its own length: a jump an adoption deferred to the end of the + // ramp this one replaces is dropped (#1711 D1). + self.state.clear_deferred_jump(); if let Some(start) = start { self.state .hold_until(automation::completion_of(completion, start, ramp)); @@ -1576,7 +1579,7 @@ impl FaderCellCursor<'_> { if let Some(release) = self.release.take() && let Some(events) = self.events.take() { - events.into_state().release_hold_by(release); + release_lane_hold(events.into_state(), release); } return false; } @@ -1592,7 +1595,7 @@ impl FaderCellCursor<'_> { self.last = last; let state = events.into_state(); if let Some(release) = self.release.take() { - state.release_hold_by(release); + release_lane_hold(state, release); } self.events = Some(automation::CellEvents::new( self.program, @@ -1898,18 +1901,13 @@ impl<'a> FaderEventDriver<'a> { }); let hold_now = hold(lane, channel); let mut mode = controls::FADER_CELL_RULE.seek; - // A landing longer than `J`: the remainder of the free stage's own ramp (U3 fold - // 2), landed alone at the block's first sample so the block's discontinuities - // still jump over `J`. - let mut long_landing = None; - if let Some(length) = settle_adoption( + if settle_adoption( core::mem::replace(adoption, Adoption::None), AdoptionBlock { program: &cell_program, span, offsets_sum: *offsets_sum, hold: hold_now, - jump, }, state, ) && step.is_none() @@ -1918,14 +1916,15 @@ impl<'a> FaderEventDriver<'a> { // over the block's jump length, to the curve at the jump's completion. step = Some((0, span.start)); mode = automation::SeekMode::Ramp; - long_landing = (length > jump).then_some(length); } let (free_from, at, release) = obligation.block(hold_now, frames); if release && let Some(first) = span_time(span.start, step, 0) { // R17-10: a live write freed the gain the stage held through the previous // block, so the cell's hold of the ramp it ended is released here, owed or - // not: its grid moves the lane from this block's first sample on. + // not: its grid moves the lane from this block's first sample on (and a jump + // deferred to that ramp's end is dropped with it, #1711 D1). state.hold_until(first.saturating_sub(1)); + state.clear_deferred_jump(); } let end = at.map(|at| hold_end(span.start, step, at)); // The channel's first mute event (#1651 T3b): the first piece ends there. @@ -1966,50 +1965,22 @@ impl<'a> FaderEventDriver<'a> { 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 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; - }; - let mut landing_events = automation::CellEvents::new( - cell_program, - state, - one, - automation::SeekMode::Ramp, - length, - ); - cursor.next = landing_events.next(); - cursor.events = Some(landing_events); - } - _ => { - 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, - )); - cursor.pull(); - } - } + 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, + )); + cursor.pull(); *slot = Some(cursor); used += 1; } @@ -2118,55 +2089,52 @@ struct AdoptionBlock<'p, 'a> { span: TimelineSpan, offsets_sum: f64, hold: Option, - /// The block's jump length `J`. - jump: u32, } /// #1648 D2, at an adopted channel's first block: settle what [`LaneFaderAutomation::adopt`] left -/// owed, and whether the block must open with the adoption jump, and over how many samples. +/// owed; `true` when the block must open with the adoption jump (a ramped landing at its first +/// sample over the block's jump length `J`). /// /// A [`Adoption::Reposition`] places the cursor at the block's node time in the cell's own table -/// (#1637 D4) and reports the stage's held ramp again (batch 13b: `reposition` drops the one the -/// carried state held). That report changes no sample: a held stage only remembers a gain -/// operation of either kind, and the obligation is decided by where the gain is free, not by the -/// event's flag. It keeps the repositioned cell's events what the cell gives under a hold (held -/// events as `RememberGain`, as the test-only event probe reports them); batch 17 U3 fold, -/// MINOR 8: kept as bookkeeping, with no audible gate. On a free stage still in a ramp (a live -/// level edit over its own length, applied at this block's entry or heard before the swap, or a -/// jump) the repositioned cell holds that ramp again, and the adoption rule compares the curve -/// where the ramp ends, where the stage's target was taken (U3 fold 2, D15-7): an unmoved target -/// keeps the edit's own length, and only a moved one lands over the jump length. -/// Then, for a reposition or [`Adoption::Compare`], the adoption rule: when -/// the curve at the block's first sample (or that ramp's end), composed with this plan's `S` -/// (`checked_fader_gain(vca_compose_db(curve, S))`, #1648 departure 4), differs in bits from the -/// gain the stage aims for (a free stage's ramp target, #1711 F0; a held stage's fader gain), -/// the block opens with a jump (returns its length: `J`, or the frames -/// the free stage's own ramp has left when that is longer, so a running edit still ends when it -/// was due, on this table's curve; never shorter than `J`, so a moved target never lands faster -/// than a table change does). Otherwise a repositioned -/// cell takes the curve's value as its current target (`seed_target`: the stage already holds its -/// gain), and a continued one keeps its carried state. A block with a seek step lands the seek -/// instead (the caller's step wins): the seek sets the lane exactly where it applies. +/// (#1637 D4) and reports the stage's ramp again (batch 13b: `reposition` drops the one the +/// carried state held). On a held stage that report changes no sample: a held stage only +/// remembers a gain operation of either kind, and the obligation is decided by where the gain is +/// free, not by the event's flag (batch 17 U3 fold, MINOR 8: kept as bookkeeping, with no audible +/// gate). On a free stage still in a ramp of its own (a live level move over its own length, +/// applied at this block's entry or heard before the swap, or a jump), the ramp is not retargeted +/// (#1711 D1, R19-1): the cell compares the curve where the ramp ends, where the stage's target +/// was taken (U3 fold 2, D15-7). An unmoved target keeps the ramp; a moved one defers the jump to +/// the ramp's first free sample ([`automation::CellState::defer_jump`]), where the cell jumps over +/// `J` as at a discontinuity of its table, so the ramp ends when it was due. +/// +/// Then, for a reposition with no such ramp, a held stage, a step of the cell's own at the +/// block's first sample (B17-R13), or [`Adoption::Compare`], the curve at the block's first +/// sample is compared: composed with this plan's `S` (`checked_fader_gain(vca_compose_db(curve, +/// S))`, #1648 departure 4), when it differs in bits from the gain the stage aims for (a free +/// stage's ramp target, #1711 F0; a held stage's fader gain) the block opens with the adoption +/// jump. A repositioned cell whose comparison does not differ takes the curve's value as its +/// current target (`seed_target`: the stage already holds its gain), and a continued one keeps +/// its carried state. A block with a seek step lands the seek instead of the adoption jump (the +/// caller's step wins), and a seek landing drops a deferred jump. fn settle_adoption( adoption: Adoption, block: AdoptionBlock<'_, '_>, state: &mut automation::CellState, -) -> Option { +) -> bool { let AdoptionBlock { program, span, offsets_sum, hold, - jump, } = block; if adoption == Adoption::None { - return None; + return false; } - // The landing's length: `J`, or the frames the free stage's own ramp has left when longer. - let mut length = jump; // The node time at which the curve is compared: the block's first sample, or the end of a // ramp the free stage still runs (U3 fold 2). let mut at = span.start; + // The free stage's ramp's first free sample, where a moved target's jump is deferred to. + let mut free = None; if adoption == Adoption::Reposition { state.reposition(program, span.start); if let Some(ChannelHold { @@ -2176,15 +2144,12 @@ fn settle_adoption( && let Some(end) = node_time_at(span, u64::from(last)) { // A held stage: the held ramp again (batch 13b). A free stage: the ramp it runs is the - // cell's own (a live level edit over its own length, or a jump) and `reposition` - // dropped its hold, so the cell holds it again and compares the curve where it ends - // (U3 fold 2, D15-7): the edit keeps its own length unless the new table moved its - // target. + // cell's own and `reposition` dropped its hold, so the cell holds it again. state.hold_until(end); // A step of the cell's own at the block's first sample jumps over `J` there, as the // cell does at any discontinuity (and as a plan prepared with this table does), so - // the curve is compared at the block's first sample and the landing is `J` - // (B17-R13): `curve_toward` looks only past `start` and would skip it. + // the curve is compared at the block's first sample (B17-R13): `curve_toward` looks + // only past `start` and would skip it. if !held && program .discontinuities() @@ -2192,7 +2157,7 @@ fn settle_adoption( .is_err() { at = end; - length = length.max(remaining); + free = node_time_at(span, u64::from(remaining)); } } } @@ -2200,10 +2165,27 @@ fn settle_adoption( let differs = FaderEventDriver::gain(value, offsets_sum) .zip(hold.and_then(|hold| hold.stage_gain)) .is_some_and(|(gain, stage)| gain.to_bits() != stage.to_bits()); - if !differs && adoption == Adoption::Reposition { - state.seed_target(value); + if !differs { + if adoption == Adoption::Reposition { + state.seed_target(value); + } + return false; + } + match free { + Some(free) => { + state.defer_jump(free); + false + } + None => true, } - differs.then_some(length) +} + +/// Release the hold of a cell whose stage stopped holding the lane at node time `release` (an +/// early unmute, #1651 T3b): the ramp a deferred jump waits for ended with it, so the deferral is +/// dropped too (#1711 D1; a held grid would otherwise freeze the lane, #1651 U1 MAJOR-1). +fn release_lane_hold(state: &mut automation::CellState, release: i64) { + state.release_hold_by(release); + state.clear_deferred_jump(); } /// The cut at offset `at` of a block whose offset 0 is node time `start`, with seek step `step`; diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index f7a654df0..e49e21c77 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -9275,6 +9275,175 @@ mod set_control { } } + /// A step ride of track `track` through `(start, end, dB)` pieces, with the stored + /// `offset`. + fn steps_ride( + track: &str, + pieces: &[(u64, u64, f32)], + offset: session::AutomationOffset, + ) -> session::Automation { + let step = session::AutomationShape::Step; + let mut automation = entry( + track, + pieces + .iter() + .map(|&(start, end, db)| segment(step, (start, end), (db, db))) + .collect(), + ); + automation.offset = offset; + automation + } + + /// One moved-target case of #1711 G2 and R19-1 on the C ABI: on [`flat`]'s ride (-3 dB), + /// a live level move to -6 dB over its own 4,000 samples from block 4 (a VCA move or a + /// hand move), heard `heard` blocks, then a table-only rebuild to -5 dB up to 6,000, then + /// -3 dB (the move's target moves), and the `later` live moves `(block, dB, ramp)`. The + /// rebuilt rig is compared with a fresh plan prepared with the reference table (-3 dB to + /// the move's first free sample, -5 dB to 6,000, then -3 dB; with later moves, the + /// rebuild's own table) and given the same moves at the same blocks: from sample `from` + /// on every sample is bit-equal over 44 blocks after the swap, every block is + /// allocation-free, and the successor is adopted. + fn moved_target_case(hand: bool, heard: usize, later: &[(u64, f32, u32)], from: u64) { + const MOVE: u32 = 4_000; + bench_support::alloc::assert_installed(); + let document = flat(); + let track = first_track(&document); + let label = &format!("hand {hand}, heard {heard}, {} later moves", later.len()); + let move_of = |document: &str, db: f32, ramp: u32| { + if hand { + hand_move(document, db, Some(ramp)) + } else { + vca_move(db, Some(ramp)) + } + }; + let mut a = Rig::new(&document); + let free = 4 * a.quantum as u64 + u64::from(MOVE); + let table = [(0, 6_000, -5.0), (6_000, SOURCE_FRAMES, -3.0)]; + let fresh_table = [ + (0, free, -3.0), + (free, 6_000, -5.0), + (6_000, SOURCE_FRAMES, -3.0), + ]; + let mut model = parse_session_json(&document).expect("model"); + model.automation[0] = steps_ride( + &track, + if later.is_empty() { + &fresh_table + } else { + &table + }, + session::AutomationOffset::default(), + ); + let fresh = session::canonical_session_json(&model).expect("canonical"); + let mut r = Rig::new(&fresh); + for rig in [&mut a, &mut r] { + for _ in 0..4 { + rig.step(); + } + } + send( + &mut a, + &[move_of(&document, -6.0, MOVE)], + protocol::EditPath::Live, + label, + ); + send( + &mut r, + &[move_of(&fresh, -6.0, MOVE)], + protocol::EditPath::Live, + label, + ); + let (mut x, mut y) = (Vec::new(), Vec::new()); + for _ in 0..heard { + x.push(a.step()); + y.push(r.step()); + } + let offset = a.model().automation[0].offset; + send( + &mut a, + &[SessionEdit::UpsertAutomation { + automation: steps_ride(&track, &table, offset), + }], + protocol::EditPath::Rebuild, + label, + ); + for _ in 0..44 { + for &(block, db, ramp) in later { + if a.block == block { + send( + &mut a, + &[move_of(&document, db, ramp)], + protocol::EditPath::Live, + label, + ); + send( + &mut r, + &[move_of(&fresh, db, ramp)], + protocol::EditPath::Live, + label, + ); + } + } + let block = a.block; + let (out, used) = a.step_counted(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "{label}: block {block} allocated or freed" + ); + x.push(out); + y.push(r.step()); + } + assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); + let quantum = a.quantum as u64; + let first = x.iter().zip(&y).enumerate().find_map(|(index, (x, y))| { + let start = (4 + index as u64) * quantum; + x.iter().zip(y).enumerate().find_map(|(i, (p, q))| { + let sample = start + i as u64 % quantum; + (sample >= from && p.to_bits() != q.to_bits()).then_some((sample, i)) + }) + }); + assert_eq!(first, None, "{label}: first (sample, index) that differs"); + } + + /// #1711 G2 on the C ABI (A3). A VCA or hand move heard 12 and 30 blocks (2,464 and 160 + /// samples left; `J` = 960) whose target a table-only rebuild moves: the cell jumps over + /// `J` at the move's first free sample (4,512), so every sample is bit-equal to a fresh + /// plan whose table has that jump as a real discontinuity there. Case (c): a second live + /// move to -9 dB over 1,200 samples from block 35 (before 4,512, ending after + /// `4,512 + J`) clears the deferral; from its last sample on every sample is bit-equal to + /// a fresh plan with the rebuild's table given both moves. + /// + /// Test value: red if the adoption lands a moved target at the swap instead of deferring + /// its jump (the previous landing over `max(J, remaining)`), or if the grid is not held + /// until the deferral fires. + #[test] + fn a_moved_target_jumps_at_a_live_moves_first_free_sample() { + for hand in [false, true] { + for heard in [12, 30] { + moved_target_case(hand, heard, &[], 0); + moved_target_case(hand, heard, &[(35, -9.0, 1_200)], 35 * 128 + 1_199); + } + } + } + + /// #1711 R19-1 on the C ABI. A VCA or hand move heard 1 and 12 blocks (3,872 and 2,464 + /// samples left, both at least `J` = 960) whose target a table-only rebuild moves: the + /// move's ramp runs to its end as the uninterrupted engine runs it, then the cell jumps + /// over `J`; every sample is bit-equal to the fresh plan with that jump at the move's + /// first free sample. + /// + /// Test value: red if the adoption retargets the move at the swap rather than deferring + /// the jump to the ramp's end. + #[test] + fn a_moved_target_lets_a_live_move_longer_than_the_jump_end_when_it_was_due() { + for hand in [false, true] { + for heard in [1, 12] { + moved_target_case(hand, heard, &[], 0); + } + } + } + /// The outcome flags and the transition-fallback count of `rig`'s watermark. fn outcome(rig: &Rig) -> (u64, u64) { let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index 6e3f7b849..f0645849d 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -2866,9 +2866,9 @@ const MOVE_BLOCK: usize = 4; #[cfg(feature = "test-support")] const RIDE_FRAMES: u64 = 48_000; -/// The live move of [`level_move_model`]: `S` to `-6` dB over 4,000 samples. +/// The live move of [`level_move_model`] at [`MOVE_BLOCK`]: `S` to `-6` dB over 4,000 samples. #[cfg(feature = "test-support")] -const LEVEL_MOVE: (f64, u32) = (-6.0, 4_000); +const MOVE: (usize, (f64, u32)) = (MOVE_BLOCK, (-6.0, 4_000)); /// Two tracks (two bank lanes) at quantum 128 with fader length 20 ms (`J` = 960), track 0 /// riding linearly from `0` to `-12` dB over [`RIDE_FRAMES`] (split in two at `split.0` with the @@ -2876,8 +2876,6 @@ const LEVEL_MOVE: (f64, u32) = (-6.0, 4_000); /// stored offset `hand` dB and, for a VCA move, a VCA of track 0 at `vca` dB. #[cfg(feature = "test-support")] fn level_move_model(split: Option<(u64, f32)>, hand: f32, vca: Option) -> SessionModel { - let mut model = plain(2, 128); - model.control_smoothing = smoothing(20.0); let lin = AutomationShape::Linear; let segments = match split { None => vec![segment(lin, 0, RIDE_FRAMES, 0.0, -12.0)], @@ -2889,6 +2887,14 @@ fn level_move_model(split: Option<(u64, f32)>, hand: f32, vca: Option) -> S ] } }; + move_model(segments, hand, vca) +} + +/// [`level_move_model`]'s session with track 0's ride `segments`. +#[cfg(feature = "test-support")] +fn move_model(segments: Vec, hand: f32, vca: Option) -> SessionModel { + let mut model = plain(2, 128); + model.control_smoothing = smoothing(20.0); let mut entry = fader_entry(&model, 0, ParameterChannel::Both, segments); (entry.offset.left_db, entry.offset.right_db) = (hand, hand); model.automation = vec![entry]; @@ -2921,14 +2927,15 @@ struct LevelMoveRun { } /// `a` prepared with live controls on `backend` and rendered for `blocks` blocks of DC `1.0`; -/// at [`MOVE_BLOCK`]'s entry the live offsets record [`LEVEL_MOVE`] is written on both channels -/// of track 0. With `b = Some((next, committed, swap))`, `next` is prepared as the successor of +/// at each `(block, record)` of `moves`, that block's entry writes the live offsets record +/// `record` (`S` and its ramp) on both channels of track 0, to the plan that renders it. With `b = Some((next, committed, swap))`, `next` is prepared as the successor of /// the plan on the committed model `committed` and swapped in at block `swap` (the C ABI's /// carry: `adopt_persisting`, the successor's gate published at the next revision). #[cfg(feature = "test-support")] fn level_move_run( a: &SessionModel, b: Option<(&SessionModel, &SessionModel, usize)>, + moves: &[(usize, (f64, u32))], backend: graph_compiler::Backend, blocks: usize, ) -> LevelMoveRun { @@ -3008,8 +3015,11 @@ fn level_move_run( .commit(); successor_sources = Some(next_sources); } - if block == MOVE_BLOCK { - let (offsets_sum, smoothing_samples) = LEVEL_MOVE; + for &(_, (offsets_sum, smoothing_samples)) in moves.iter().filter(|(at, _)| *at == block) { + let (handles, gate) = match successor_gate.as_mut() { + Some((handles, gate)) => (handles, gate), + None => (&mut handles, &mut gate), + }; gate.write_published(|stamp| { for channel in 0..2 { handles.strip_controls[0].fader.write_offsets( @@ -3100,18 +3110,24 @@ fn a_level_move_in_flight_across_a_table_only_rebuild_keeps_its_bits() { for heard in [1, 12, 24, 26, 30] { let swap = MOVE_BLOCK + heard; let blocks = swap + AFTER; - let reference = level_move_run(&a, None, backend, blocks); + let reference = level_move_run(&a, None, &[MOVE], backend, blocks); assert_eq!(reference.allocations, (0, 0, 0)); for (change, split) in changes { let label = format!("{backend:?}, {what}, heard {heard}, {change}"); let next = level_move_model(Some(split), hand, vca.map(|vca| vca.1)); - let run = level_move_run(&a, Some((&next, &committed, swap)), backend, blocks); + let run = level_move_run( + &a, + Some((&next, &committed, swap)), + &[MOVE], + backend, + blocks, + ); assert_eq!(run.adopted, Some(swap), "{label}: adopted at the swap"); assert!(run.restarted.is_empty(), "{label}: the strip carries"); assert_eq!(run.allocations, (0, 0, 0), "{label}: allocation-free"); assert_ne!( reference.blocks[swap - 1], - level_move_run(&committed, None, backend, swap).blocks[swap - 1], + level_move_run(&committed, None, &[], backend, swap).blocks[swap - 1], "{label}: the move is still heard before the swap" ); let first = run @@ -3126,6 +3142,170 @@ fn a_level_move_in_flight_across_a_table_only_rebuild_keeps_its_bits() { } } +// #1711 G2 and R19-1: a live level move whose target a table-only rebuild moves. + +/// The first free sample of [`MOVE`]'s ramp: the block-4 entry plus its 4,000 samples. +#[cfg(feature = "test-support")] +const MOVE_FREE: u64 = MOVE_BLOCK as u64 * 128 + MOVE.1.1 as u64; + +/// A step ride of track 0 through `(start, end, dB)` pieces. +#[cfg(feature = "test-support")] +fn steps_of(pieces: &[(u64, u64, f32)]) -> Vec { + pieces + .iter() + .map(|&(start, end, db)| segment(AutomationShape::Step, start, end, db, db)) + .collect() +} + +/// The moved-target setup (#1711 A3 G2): the ride flat at -3 dB; the rebuild's table -5 dB to +/// 6,000, then -3 dB (the move's target moves from -3 to -5 dB); the reference table, -3 dB to the +/// move's first free sample [`MOVE_FREE`], -5 dB to 6,000, then -3 dB. +#[cfg(feature = "test-support")] +fn moved_target_tables() -> [Vec; 3] { + [ + steps_of(&[(0, RIDE_FRAMES, -3.0)]), + steps_of(&[(0, 6_000, -5.0), (6_000, RIDE_FRAMES, -3.0)]), + steps_of(&[ + (0, MOVE_FREE, -3.0), + (MOVE_FREE, 6_000, -5.0), + (6_000, RIDE_FRAMES, -3.0), + ]), + ] +} + +/// One moved-target case at `backend`: [`MOVE`] heard `heard` blocks on the flat ride, then the +/// table-only rebuild of [`moved_target_tables`], and the `later` live moves after the swap. The +/// rebuilt run is compared with a fresh plan prepared with `reference` (the reference table, +/// or the rebuild's own with later moves) and given the same moves, from sample `from` on: every +/// sample bit-equal, allocation-free, adopted at the swap block, the strip carries. Returns the +/// rebuilt run's left plane. +#[cfg(feature = "test-support")] +fn moved_target_case( + backend: graph_compiler::Backend, + (what, hand, vca): MovedTargetMove, + heard: usize, + later: &[(usize, (f64, u32))], + from: u64, +) -> Vec { + const AFTER: usize = 44; + let [flat, table, fresh] = moved_target_tables(); + let swap = MOVE_BLOCK + heard; + let blocks = (swap + AFTER).max(later.iter().map(|(at, _)| at + 20).max().unwrap_or(0)); + let a = move_model(flat.clone(), 0.0, vca.map(|vca| vca.0)); + let committed = move_model(flat, hand, vca.map(|vca| vca.1)); + let next = move_model(table.clone(), hand, vca.map(|vca| vca.1)); + let reference = move_model( + if later.is_empty() { fresh } else { table }, + 0.0, + vca.map(|vca| vca.0), + ); + let moves: Vec<_> = core::iter::once(MOVE) + .chain(later.iter().copied()) + .collect(); + let label = format!( + "{backend:?}, {what}, heard {heard}, {} later moves", + later.len() + ); + let run = level_move_run(&a, Some((&next, &committed, swap)), &moves, backend, blocks); + let expected = level_move_run(&reference, None, &moves, backend, blocks); + assert_eq!(run.adopted, Some(swap), "{label}: adopted at the swap"); + assert!(run.restarted.is_empty(), "{label}: the strip carries"); + assert_eq!(run.allocations, (0, 0, 0), "{label}: allocation-free"); + let planes = |run: &LevelMoveRun, plane: usize| -> Vec { + run.blocks + .iter() + .flat_map(|block| block[plane * 128..(plane + 1) * 128].iter().copied()) + .collect() + }; + for plane in 0..2 { + let (x, y) = (planes(&run, plane), planes(&expected, plane)); + let first = (from as usize..x.len()).find(|&sample| x[sample] != y[sample]); + assert_eq!( + first, None, + "{label}: plane {plane}, first sample that differs" + ); + } + planes(&run, 0).into_iter().map(f32::from_bits).collect() +} + +/// One move of the moved-target cases: (label, the stored hand offset after the move, the VCA +/// before and after it). +#[cfg(feature = "test-support")] +type MovedTargetMove = (&'static str, f32, Option<(f32, f32)>); + +/// The two moves of the moved-target cases. +#[cfg(feature = "test-support")] +const MOVED_TARGET_MOVES: [MovedTargetMove; 2] = [ + ("a VCA move", 0.0, Some((0.0, -6.0))), + ("a hand move", -6.0, None), +]; + +/// #1711 G2 on host-core, at both bank widths. [`MOVE`] heard 12 and 30 blocks (2,464 and 160 +/// samples left; `J` = 960) on a ride flat at -3 dB, then a table-only rebuild to -5 dB up to +/// 6,000: the move's target moves, so the cell jumps over `J` at the move's first free sample +/// (4,512), not at the swap. Every sample is bit-equal to a fresh plan whose table has that jump +/// as a real discontinuity at 4,512 and that is given the same move. Case (c): a second live move +/// (`S` to -9 dB over 1,200 samples from block 35, before 4,512, ending after `4,512 + J`) clears +/// the deferral: from its last sample on, every sample is bit-equal to a fresh plan with the new +/// table given both moves, and the lane still moves on that move's last samples. +/// +/// Test value: red if the adoption lands a moved target at the swap instead of deferring its jump +/// (the old landing; checked once), if the grid is not held until the deferral fires (the grid +/// point 32 samples before the move's end restarts the ramp toward -5 dB; checked once), or, case +/// (c), if the newer move does not clear the deferral (the deferred jump then cuts the move short +/// at `4,512 + J`; checked once). The comparison point `at = end` is not observable here (on a +/// flat table both points read -5 dB); G1 (`a_level_move_in_flight_across_a_table_only_rebuild_ +/// keeps_its_bits`) holds it. +#[cfg(feature = "test-support")] +#[test] +fn a_moved_target_jumps_over_the_fader_length_at_the_moves_first_free_sample() { + const SECOND: (usize, (f64, u32)) = (35, (-9.0, 1_200)); + let second_last = SECOND.0 as u64 * 128 + u64::from(SECOND.1.1) - 1; + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + for case in MOVED_TARGET_MOVES { + for heard in [12, 30] { + moved_target_case(backend, case, heard, &[], 0); + let left = moved_target_case(backend, case, heard, &[SECOND], second_last); + let last = second_last as usize; + assert_ne!( + left[last - 1].to_bits(), + left[last].to_bits(), + "{backend:?}, {}, heard {heard}: the second move keeps its own length", + case.0 + ); + } + } + } +} + +/// #1711 R19-1 on host-core, at both bank widths. [`MOVE`] heard 1 and 12 blocks (3,872 and +/// 2,464 samples left, both at least `J` = 960) on the flat ride, then the table-only rebuild that +/// moves its target: the move's ramp runs to its end as the uninterrupted engine runs it, then +/// the cell jumps over `J`. Every sample is bit-equal to the fresh plan with that jump at the +/// move's first free sample. +/// +/// Test value: red if the adoption retargets the move at the swap (over its remaining frames, or +/// over `J`) instead of deferring the jump to the ramp's end. +#[cfg(feature = "test-support")] +#[test] +fn a_moved_target_lets_a_live_move_longer_than_the_jump_end_when_it_was_due() { + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + for case in MOVED_TARGET_MOVES { + for heard in [1, 12] { + moved_target_case(backend, case, heard, &[], 0); + } + } + } +} + #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] mod canonical_preparation { use super::*; diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 856f1dac1..ea5cce6a8 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -3011,6 +3011,75 @@ fn a_reposition_swap_while_a_level_move_ramps_reports_the_fallback_alike_on_both } } +/// #1711 G2 across hosts: on a fader ride flat at -3 dB, a hand move with its own 4,000 samples +/// heard 3 blocks, then a table-only rebuild to -5 dB (the move's target moves): the adoption +/// defers the cell's jump to the move's first free sample on both hosts alike, through the one +/// shared driver. Both hosts give one verdict and reply per edit and render the same bits in every +/// block, through the move's end and the deferred jump over the fader length. +/// +/// Test value: red if either host adopts the moved target differently (an adapter-local rule, or +/// a browser successor that does not carry the stage's ramp the deferral waits for). +#[test] +fn a_moved_target_defers_its_jump_alike_on_both_hosts() { + let ride = |pieces: &[(u64, u64, f32)]| SessionEdit::UpsertAutomation { + automation: session::Automation { + id: id("t1-fader-ride"), + target: address( + Owner::Strip(id(T1.id)), + Section::Builtins, + 5, + ParameterChannel::Both, + ), + segments: pieces + .iter() + .map(|&(start, end, db)| session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: start, + end_sample: end, + start_value: db, + end_value: db, + unit: session::ParameterUnit::Db, + }) + .collect(), + 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"); + }; + both(&[ride(&[(0, 1_000_000, -3.0)])], &mut browser, &mut c_abi); + same_blocks(&mut browser, &mut c_abi, 0..4, "the flat ride"); + both(&hand, &mut browser, &mut c_abi); + same_blocks(&mut browser, &mut c_abi, 4..7, "the hand move"); + both( + &[ride(&[(0, 20_000, -5.0), (20_000, 1_000_000, -3.0)])], + &mut browser, + &mut c_abi, + ); + same_blocks(&mut browser, &mut c_abi, 7..60, "the moved target"); + assert_eq!( + status_watermark(&browser), + c_abi.watermark(), + "{rate} Hz: the browser status carries the C ABI's watermark" + ); + } +} + /// #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 From 3d4b70c1403c09cfe55baa23562a100efea02dd9 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 13:07:44 +0000 Subject: [PATCH 07/36] Render VCA rows in the controls rules and store a hand VCA move as an offset (#1655 V0) VCA rows offset_db and mute join automation_row_renders; offset_db takes a per-lane hand offset (fader domain); the in-place owner rule covers it and the listing reports the offset with automated = true. New event-lane predicates and the reach rule (a reach change of an automated VCA is a rebuild). S sums own hand offset, static offsets of non-automated VCAs, then hand offsets of automated VCAs (root's class B order); with no VCA offset automation every bit is unchanged. Co-Authored-By: Claude Opus 5.5 --- ...r-stored-vca-offset-and-mute-automation.md | 65 +++ crates/controls/src/automation.rs | 203 +++++++- crates/controls/src/effective.rs | 77 ++- crates/controls/src/lib.rs | 8 +- crates/controls/src/mask.rs | 36 +- crates/controls/src/owner_edits.rs | 23 +- crates/controls/src/registry.rs | 32 +- crates/controls/src/vca.rs | 79 +++- crates/controls/src/view.rs | 22 + crates/controls/tests/automation.rs | 438 ++++++++++++++++++ 10 files changed, 931 insertions(+), 52 deletions(-) diff --git a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md index dc344e299..24b7bafae 100644 --- a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md +++ b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md @@ -386,3 +386,68 @@ These override the batch 19 spec-check amendment where they differ. gate at the cap: one pair over is refused with a typed diagnostic, not a panic. - **Two PRs.** This issue ships alone in batch 19b, on top of 19a (#1711, K1, #1654). Edit order: every shared file after 19a. + +## Decision record: V0 (batch 19) + +`crates/controls` only. No render, host-core, graph or builtins-compiler code; no row class or +table change, so no SDK regeneration. + +- **Rows (D6).** `automation_row_renders` admits VCA row 1 (`vca_offset_row`) and row 2 + (`vca_mute_row`). `fader_row` is unchanged, so a VCA entry reaches no fader-only reader. New + accessors: `vca_offset_automation`, `vca_mute_automation` (per lane, first entry in table order; + the strip readers now share one `owner_row_automation` walk). +- **Offset (D5).** `takes_offset` = `fader_row || route_gain_row || vca_offset_row`. The VCA row is + per lane, so `offset_faults` judges each driven lane against the row's own domain + (`[-144, 24]` dB, the fader domain); a lane the entry does not drive holds `+0.0`; the mute row + takes no offset. +- **Owner rule (R17-1).** `registry::write` on an automated VCA `offset_db` lane writes that lane's + entry offset (`vca::write_offset`) and the static offset only on lanes no entry drives; + `vca::resolve` and the list read the offset as the row's value; both VCA rows list + `automated = true` on a driven lane (the `mute` row keeps its static hand mute as its value). + `OwnerEdits::set` on VCA row 1 clones the driving entries; `ModelView::vca_automation` reads the + edited entry. A member's own rows are not marked by a VCA ride. +- **Mask.** `mask_fader_offsets` copies VCA-offset entry offsets (through `takes_offset`); + `mask_values` keeps `into`'s static offset on each automated VCA lane (fallback = structure), + and copies the rest of the VCA list (other lanes, mutes, members) as before. +- **Event lanes.** `fader_event_lanes(model, strip, reach)` and `mute_event_lanes(model, strip, + reach)`: own entry on the row, or a reaching VCA with an entry on the matching VCA row and lane. + `reach` is the caller's VCA reach of the strip (`vca_reach` / `vca_reach_of`), so preparation and + the classifier, which already hold it, build no second table. The own-entry readers + (`fader_automation`, `model_fader_offsets`, `mask_fader_offsets`, the listing flags) stay + own-entry only. +- **Reach rule.** `rendered_automation_changed` also returns `true` when, with equal rendered + entries, the strip IDs reached by some VCA that a rendered entry drives differ (inverse of + `SessionModel::vca_reach`, which reads a model in any strip order). No table is built when no + rendered entry is on a VCA row. +- **`S` (D2).** `EffectiveInputs` gains `vca_offset(vca)` (the driving entries' hand offsets per + lane; `ModelInputs` precomputes it, `LocalInputs` reads the view). `effective_strip` sums: own + hand offset, then static offsets of reaching VCAs whose lane is not automated, then hand offsets + of those whose lane is, each in ascending VCA ID. With no VCA offset entry the second pass does + not run and `S` is today's sum. `fader_db` stays the static composition (a lane an automated VCA + reaches is a fader event lane, which takes no `FaderDb` record). Departure: none from the text; + `EffectiveInputs` is a public trait with no implementor outside the crate. +- **Not in V0** (render tranche): `continued_fader_lanes` / `continued_mute_lanes` are not widened + to the reaching VCA tables (D6 carry). + +Gates (`crates/controls/tests/automation.rs`): `vca_rows_render_and_take_a_per_lane_offset`, +`a_hand_move_on_an_automated_vca_offset_writes_its_offset` (whole and in-place paths, list flags, +`S` on both effective paths), `the_mask_copies_a_vca_offset_and_keeps_its_fallback`, +`event_lanes_take_the_own_entry_and_every_reaching_vca_ride` (lanes, rows, nesting, own-entry +readers), `a_reach_change_of_an_automated_vca_rebuilds` (members added or removed, nesting, a +non-automated VCA, a hand offset, the mute row), `the_offset_sum_puts_the_static_vca_offsets_first` +(values `1, 2^-60, -1` that tell the two orders apart; no VCA offset automation and a VCA mute +entry alone keep every bit of `S` and `fader_db`). + +Commands: `cargo test --locked -p controls` (all green), `cargo clippy --locked -p controls +--all-targets --all-features -- -D warnings`, `cargo check --locked -p controls --all-targets`, +`cargo test --locked -p host-core --features test-support` (467 passed), +`--features test-support,control-provider` (587 passed), `cargo test --locked -p control-plane +--features test-support` (28 passed). No red: no host-core or control-plane test edits a VCA +automation entry or an automated VCA's reach today. + +For V1: a VCA automation edit and an automated VCA's reach change are now step 3 `Automation` +(rebuild) in the classifier; a session whose only rendered entries are VCA entries now enters +`prepare_fader_automation` (it builds no VCA cell yet; tests green); a hand move on an automated +VCA offset diffs as a VCA row 1 change whose value is the offset and moves `S` of each reached +lane, which host-core lowers only on lanes that have an offsets cell until V1 gives every event +lane one. diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index b4044bd47..16cc6d7b9 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -16,7 +16,9 @@ use crate::error::ControlError; use crate::lane::Side; use crate::registry::resolve; use crate::route::{ROUTE_AUTOMATED_ROWS, ROUTE_GAIN_DB}; +use crate::vca::{VCA_MUTE, VCA_OFFSET_DB}; use session::Lane; +use std::collections::BTreeMap; /// Whether `target` names an effect instance (a console slot or an insert) whose identity has no /// control rows: a `cid` effect, or a native effect the launch registry lacks. @@ -47,19 +49,20 @@ fn effect_has_no_rows(model: &SessionModel, target: &ControlAddress) -> bool { } /// Whether the row at `target` takes a stored hand-move offset (#1650 D2, root's ruling R17-6): a -/// strip's builtin `fader_db` and a route's `gain_db` (row 1, [`route_gain_row`], #1654). Only a -/// route into a submix can hold an accepted entry on the route row (an output route's row is not -/// automatable and is refused at `.target` first, D8), so the predicate needs no model. The slice -/// that renders the input trim (#1658) adds its row here. +/// strip's builtin `fader_db`, a route's `gain_db` (row 1, [`route_gain_row`], #1654) and a VCA's +/// `offset_db` (row 1, [`vca_offset_row`], per lane, #1655 D5 and root's ruling of 2026-10-14: a +/// level control). Only a route into a submix can hold an accepted entry on the route row (an +/// output route's row is not automatable and is refused at `.target` first, D8), so the predicate +/// needs no model. The slice that renders the input trim (#1658) adds its row here. pub(crate) fn takes_offset(target: &ControlAddress) -> bool { - fader_row(target) || route_gain_row(target) + fader_row(target) || route_gain_row(target) || vca_offset_row(target) } /// The offset rule of one entry whose target is accepted (#1650 D2), per lane, left then right: /// on a lane that takes no offset (the entry does not drive it, or its row takes none) the /// offset is exactly `+0.0`, else `control.automation.offset`; on a driven lane of a row that -/// takes one, the offset lies in the row's own domain (`fader_db` and a send's `gain_db`: -/// `[-144, 24]` dB), else `control.domain`. `-0.0` is a valid zero only on a lane that takes an +/// takes one, the offset lies in the row's own domain (`fader_db`, a send's `gain_db` and a VCA's +/// `offset_db`: `[-144, 24]` dB), else `control.domain`. `-0.0` is a valid zero only on a lane that takes an /// offset, so a lane without one has one spelling. /// /// A shared row (a send's `gain_db`, R17-6) holds one offset in two fields: `left_db` is judged @@ -126,8 +129,8 @@ pub struct ControlDiagnostic { /// `input_filter_order`). It is reported once per lane, at the LPF entry's segment when the LPF /// is automated, else at the HPF entry's; /// - `control.automation.offset`: a hand-move offset (#1650 D2) that is not `+0.0` on a lane the -/// entry does not drive, or on any lane of a row that takes no offset (only `fader_db` and a -/// send's `gain_db` take one, R17-6), or a send `gain_db` entry whose `right_db` bits differ +/// entry does not drive, or on any lane of a row that takes no offset (only `fader_db`, a +/// send's `gain_db` and a VCA's `offset_db` take one, R17-6, #1655 D5), or a send `gain_db` entry whose `right_db` bits differ /// from its `left_db` (the shared row holds one offset); `control.domain` for an offset outside /// the row's domain. Each is reported at `.offset.left_db` or `.offset.right_db`, after the /// entry's segments. @@ -401,17 +404,116 @@ pub const MUTE_CELL_RULE: CellRule = CellRule { seek: SeekMode::Exact, }; -/// Whether a stored automation of `target` renders (#1646, #1647, #1651, #1654): the fader row of -/// a strip (builtin row 5, [`fader_row`]), its mute row (builtin row 6, [`mute_row`]) and a send's -/// rows 1-6 (`gain_db`, `mute`, `matrix_*`, [`route_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. +/// Whether a stored automation of `target` renders (#1646, #1647, #1651, #1654, #1655): the fader +/// row of a strip (builtin row 5, [`fader_row`]), its mute row (builtin row 6, [`mute_row`]), a +/// send's rows 1-6 (`gain_db`, `mute`, `matrix_*`, [`route_row`]) and a VCA's rows 1 and 2 +/// (`offset_db`, [`vca_offset_row`], and `mute`, [`vca_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): neither the mute row -/// nor a route row gives a lane a fader cell, a fader hand-move offset or B17-R14's window. +/// A reader that means "the fader" tests [`fader_row`] instead (#1651 T2): neither the mute row, +/// a route row nor a VCA row gives a lane a fader cell, a fader hand-move offset or B17-R14's +/// window. A VCA ride makes its members' lanes event lanes ([`fader_event_lanes`], +/// [`mute_event_lanes`]); it does not automate a member's own fader. #[must_use] pub fn automation_row_renders(target: &ControlAddress) -> bool { - fader_row(target) || mute_row(target) || route_row(target) + fader_row(target) || mute_row(target) || route_row(target) || vca_row(target) +} + +/// Whether `target` is a VCA's `offset_db` row (row 1, #1655): per lane, a level control that +/// takes a hand-move offset (D5). +#[must_use] +pub fn vca_offset_row(target: &ControlAddress) -> bool { + matches!(target.owner, Owner::Vca(_)) + && target.section == Section::Own + && target.control == VCA_OFFSET_DB +} + +/// Whether `target` is a VCA's `mute` row (row 2, #1655): per lane, a toggle whose curve ORs into +/// each reached member lane's stage mute (D4); a hand VCA mute stays a live static value. +#[must_use] +pub fn vca_mute_row(target: &ControlAddress) -> bool { + matches!(target.owner, Owner::Vca(_)) + && target.section == Section::Own + && target.control == VCA_MUTE +} + +/// Whether `target` is one of a VCA's rendered rows ([`vca_offset_row`] or [`vca_mute_row`]). +fn vca_row(target: &ControlAddress) -> bool { + vca_offset_row(target) || vca_mute_row(target) +} + +/// The stored automation entries that drive VCA `vca`'s `offset_db`, per lane (`[left, right]`), +/// by the rule of [`fader_automation`] on [`vca_offset_row`] (#1655): the first entry per lane in +/// table order. An empty table returns at once. +#[must_use] +pub fn vca_offset_automation<'a>( + model: &'a SessionModel, + vca: &StableId, +) -> [Option<&'a Automation>; 2] { + owner_row_automation(model, vca, vca_offset_row) +} + +/// The stored automation entries that drive VCA `vca`'s `mute`, per lane (`[left, right]`), by the +/// rule of [`vca_offset_automation`] on [`vca_mute_row`] (#1655). +#[must_use] +pub fn vca_mute_automation<'a>( + model: &'a SessionModel, + vca: &StableId, +) -> [Option<&'a Automation>; 2] { + owner_row_automation(model, vca, vca_mute_row) +} + +/// The lanes of strip `strip` that are fader event lanes (#1655 D2, batch 19 amendment), per lane +/// (`[left, right]`): the strip's own fader lane is automated ([`automated_fader_lanes`]), or an +/// automated VCA `offset_db` lane reaches it (VCA lane `l` reaches its members' lane `l`). `reach` +/// is the strip's VCA reach in `model` (its entry of [`SessionModel::vca_reach`], or +/// [`SessionModel::vca_reach_of`]): indices into `model.vcas`. The one rule of preparation, the +/// classifier, the strip join and the route attach. An empty table returns at once. +/// +/// A reader that means "this strip's own entry" (the `fader_db` row's value and flag, the lane's +/// own hand offset, the mask) reads [`fader_automation`] instead. +#[must_use] +pub fn fader_event_lanes(model: &SessionModel, strip: &StableId, reach: &[usize]) -> [bool; 2] { + event_lanes( + model, + reach, + automated_fader_lanes(model, strip), + vca_offset_automation, + ) +} + +/// The lanes of strip `strip` that are mute event lanes (#1655 D4, batch 19 amendment), per lane +/// (`[left, right]`): the strip's own mute lane is automated ([`mute_automation`]), or an +/// automated VCA `mute` lane reaches it. `reach` is as for [`fader_event_lanes`]. A following send +/// of such a lane takes the seven-word route cell. +#[must_use] +pub fn mute_event_lanes(model: &SessionModel, strip: &StableId, reach: &[usize]) -> [bool; 2] { + if model.automation.is_empty() { + return [false; 2]; + } + let own = mute_automation(model, strip).map(|entry| entry.is_some()); + event_lanes(model, reach, own, vca_mute_automation) +} + +/// `own`, ORed per lane with whether some VCA of `reach` has an entry `entries` gives on that lane. +fn event_lanes<'a>( + model: &'a SessionModel, + reach: &[usize], + own: [bool; 2], + entries: fn(&'a SessionModel, &StableId) -> [Option<&'a Automation>; 2], +) -> [bool; 2] { + let mut lanes = own; + if model.automation.is_empty() || lanes == [true; 2] { + return lanes; + } + for vca in reach.iter().filter_map(|&index| model.vcas.get(index)) { + let driven = entries(model, &vca.id); + for lane in 0..2 { + lanes[lane] |= driven[lane].is_some(); + } + } + lanes } /// Whether `target` is one of a route's rendered rows 1-6 (`gain_db`, `mute`, `matrix_ll`, @@ -513,7 +615,7 @@ pub fn fader_automation<'a>( model: &'a SessionModel, strip: &StableId, ) -> [Option<&'a Automation>; 2] { - row_automation(model, strip, fader_row) + owner_row_automation(model, strip, fader_row) } /// The stored automation entries that drive strip `strip`'s mute, per lane (`[left, right]`), by @@ -523,20 +625,24 @@ pub fn mute_automation<'a>( model: &'a SessionModel, strip: &StableId, ) -> [Option<&'a Automation>; 2] { - row_automation(model, strip, mute_row) + owner_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>( +/// The entries on the row `row` accepts whose owner (a strip or a VCA; `row` fixes the kind) has +/// the ID `owner`, per lane; the first entry per lane in table order. +fn owner_row_automation<'a>( model: &'a SessionModel, - strip: &StableId, + owner: &StableId, row: fn(&ControlAddress) -> bool, ) -> [Option<&'a Automation>; 2] { let mut lanes = [None, None]; for automation in &model.automation { let target = &automation.target; - if !row(target) || !matches!(&target.owner, Owner::Strip(owner) if owner == strip) { + let id = match &target.owner { + Owner::Strip(id) | Owner::Vca(id) => id, + Owner::Route(_) | Owner::Session => continue, + }; + if !row(target) || id != owner { continue; } for (index, lane) in [Lane::Left, Lane::Right].into_iter().enumerate() { @@ -606,6 +712,15 @@ pub fn automated_fader_lanes(model: &SessionModel, strip: &StableId) -> [bool; 2 /// /// A difference needs a plan rebuild: a plan's cells are compiled from these entries at /// preparation, so no live record can carry a changed curve. +/// +/// With equal entries, it is also a difference when the `(member strip, lane)` set that an +/// automated VCA lane reaches differs between the two models (#1655 D5, root's ruling of +/// 2026-10-14): a membership or nesting edit of a VCA that some rendered entry drives. Each newly +/// reached pair needs new event state, so only a rebuild can carry it (decision 14 rule 1). An +/// automated VCA added or removed is already a difference of the entries. A VCA lane `l` reaches +/// its members' lane `l`, and the entries (so the automated lanes) are equal here, so the rule +/// compares, per automated VCA (by ID), the strip IDs it reaches ([`SessionModel::vca_reach`]). A reach change that involves only VCAs no rendered entry drives +/// stays D15-6's live update. #[must_use] pub fn rendered_automation_changed(current: &SessionModel, next: &SessionModel) -> bool { fn rendered(model: &SessionModel) -> impl Iterator { @@ -617,11 +732,49 @@ pub fn rendered_automation_changed(current: &SessionModel, next: &SessionModel) let (mut before, mut after) = (rendered(current), rendered(next)); loop { match (before.next(), after.next()) { - (None, None) => return false, + (None, None) => break, (Some(before), Some(after)) if same_curve(before, after) => {} _ => return true, } } + vca_reach_changed(current, next) +} + +/// Whether some VCA that a rendered entry of `next` drives ([`vca_row`]) is reached by another set +/// of strips in `current` than in `next` (the reach rule of [`rendered_automation_changed`]). No +/// reach table is built when no rendered entry is on a VCA row. +fn vca_reach_changed(current: &SessionModel, next: &SessionModel) -> bool { + let mut automated: Vec<&StableId> = next + .automation + .iter() + .filter(|automation| vca_row(&automation.target)) + .filter_map(|automation| match &automation.target.owner { + Owner::Vca(id) => Some(id), + _ => None, + }) + .collect(); + if automated.is_empty() { + return false; + } + automated.sort_unstable(); + automated.dedup(); + let (before, after) = (reached_strips(current), reached_strips(next)); + automated + .into_iter() + .any(|vca| before.get(vca) != after.get(vca)) +} + +/// Per VCA ID of `model`, the IDs of the strips that reach it, in strip order: the inverse of +/// [`SessionModel::vca_reach`], which reads any model (a strip list in any order). A VCA no strip +/// reaches has no key. +fn reached_strips(model: &SessionModel) -> BTreeMap<&StableId, Vec<&StableId>> { + let mut reached: BTreeMap<&StableId, Vec<&StableId>> = BTreeMap::new(); + for (strip, reach) in model.strips().zip(model.vca_reach()) { + for vca in reach.into_iter().filter_map(|index| model.vcas.get(index)) { + reached.entry(&vca.id).or_default().push(strip.id); + } + } + reached } /// Whether two entries have the same `id`, `target` and `segments`, `f32` values by bits. diff --git a/crates/controls/src/effective.rs b/crates/controls/src/effective.rs index 223209189..1deeb9cc9 100644 --- a/crates/controls/src/effective.rs +++ b/crates/controls/src/effective.rs @@ -192,18 +192,24 @@ pub struct MonitorChange { /// One strip's effective values, per lane (`[left, right]`). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct EffectiveStrip { - /// The fader in dB: [`session::vca_effective_db`] over the reaching VCAs' offsets. + /// The fader in dB: [`session::vca_effective_db`] over the reaching VCAs' static offsets + /// (an automated VCA lane's static offset is its fallback, #1655 D5: a lane it reaches is a + /// fader event lane, which takes no `FaderDb` record). pub fader_db: [f32; 2], /// The mute: the strip's own, any reaching VCA's, or the solo term. pub mute: [bool; 2], /// Whether any reaching VCA mutes the lane. pub vca_mute: [bool; 2], - /// `S`, the lane's offset sum (#1646 D1, D6; #1650 D6): the `f64` sum of the lane's own - /// hand-move offset ([`Self::fader_offset`], when an entry drives the lane) first, then the - /// reaching VCAs' offsets in ascending VCA-ID order ([`session::vca_offsets_sum`]'s order), and - /// `+0.0` when there is neither. A stored fader automation value `v` renders as - /// `session::vca_compose_db(v, S)`. The static path composes the member with the VCA offsets - /// alone (`fader_db`): a lane with no driving entry has no hand offset, so the two agree there. + /// `S`, the lane's offset sum (#1646 D1, D6; #1650 D6; #1655 D2): the `f64` sum of the lane's + /// own hand-move offset ([`Self::fader_offset`], when an entry drives the lane) first, then the + /// static offsets of the reaching VCAs whose `offset_db` lane no rendered entry drives, then + /// the hand-move offsets of the reaching VCAs whose lane one does, each group in ascending + /// VCA-ID order ([`session::vca_offsets_sum`]'s order), and `+0.0` when there is none. A + /// stored fader automation value `v` renders as `session::vca_compose_db(v, S)`, and each + /// reaching VCA curve adds its value after `S` (D2). With no VCA automation the middle group + /// is every reaching VCA's static offset and the last is empty, so `S` keeps its bits. 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 fader-row stored automation entry ([`crate::fader_row`]) that /// drives each lane (#1650 D3), or `None` for a lane no fader-row entry drives. @@ -295,6 +301,9 @@ pub trait EffectiveInputs { /// The hand-move offset (dB) of the rendered stored automation entry that drives each of the /// strip's fader lanes, or `None` for a lane no rendered entry drives (#1650 D3, D6). fn fader_offset(&self, strip: usize) -> [Option; 2]; + /// The hand-move offset (dB) of the rendered stored automation entry that drives each of a + /// VCA's `offset_db` lanes, or `None` for a lane no rendered entry drives (#1655 D2, D5). + fn vca_offset(&self, vca: usize) -> [Option; 2]; /// The number of routes. fn route_count(&self) -> usize; /// One route's inputs. @@ -319,7 +328,9 @@ pub fn solo_term(inputs: &impl EffectiveInputs, strip: usize, any_solo: bool) -> /// /// Per lane: `fader_db = vca_effective_db(own, the reaching VCAs' offsets in ascending VCA-ID /// order)`; `offsets_sum` = the `f64` sum of the lane's hand-move offset (when a rendered entry -/// drives the lane) and then those VCA offsets, or `+0.0` with none (#1650 D6); `vca_mute` = +/// drives the lane), then the static offsets of the reaching VCAs no rendered entry drives on the +/// lane, then the hand-move offsets of those one does, each group in ascending VCA-ID order, or +/// `+0.0` with none (#1650 D6, #1655 D2); `vca_mute` = /// any reaching VCA mutes; `mute = own || vca_mute || solo_term` with /// `solo_term = any_solo && !solo_safe && !soloed`. #[must_use] @@ -345,14 +356,31 @@ pub fn effective_strip( sum.add(hand); } } + // The static offsets of the VCA lanes no entry drives, then (a second pass, only when some + // reaching VCA lane is automated) the hand offsets of those one drives (#1655 D2). + let mut automated = false; for &vca in reach { let (offset_db, muted) = inputs.vca(vca); + let hand = inputs.vca_offset(vca); for lane in 0..2 { sums[lane].add(offset_db[lane]); - offsets[lane].add(offset_db[lane]); + if hand[lane].is_none() { + offsets[lane].add(offset_db[lane]); + } else { + automated = true; + } out.vca_mute[lane] |= muted[lane]; } } + if automated { + for &vca in reach { + for (sum, hand) in offsets.iter_mut().zip(inputs.vca_offset(vca)) { + if let Some(hand) = hand { + sum.add(hand); + } + } + } + } for lane in 0..2 { out.fader_db[lane] = sums[lane].compose(own_db[lane]); out.offsets_sum[lane] = offsets[lane].sum().unwrap_or(0.0); @@ -445,6 +473,9 @@ pub struct ModelInputs<'a> { /// The hand-move offsets of each strip's automated fader lanes (#1650), indexed by strip; /// empty when the model stores no rendered fader automation. fader_offsets: Vec<[Option; 2]>, + /// The hand-move offsets of each VCA's automated `offset_db` lanes (#1655), indexed by VCA; + /// empty when the model stores no rendered VCA offset automation. + vca_offsets: Vec<[Option; 2]>, } impl<'a> ModelInputs<'a> { @@ -469,6 +500,7 @@ impl<'a> ModelInputs<'a> { reach: model.vca_reach(), sources, fader_offsets: model_fader_offsets(model), + vca_offsets: model_vca_offsets(model), } } @@ -524,6 +556,24 @@ pub(crate) fn model_fader_offsets(model: &SessionModel) -> Vec<[Option; 2]> offsets } +/// The [`EffectiveInputs::vca_offset`] of every VCA of `model`, by VCA index +/// ([`crate::vca_offset_automation`]'s rule), or an empty table when no entry is on a VCA +/// `offset_db` row. +fn model_vca_offsets(model: &SessionModel) -> Vec<[Option; 2]> { + if !model + .automation + .iter() + .any(|entry| crate::vca_offset_row(&entry.target)) + { + return Vec::new(); + } + model + .vcas + .iter() + .map(|vca| crate::builtin_rows::fader_offsets(crate::vca_offset_automation(model, &vca.id))) + .collect() +} + fn source_id(route: &Route) -> &StableId { match &route.source { RouteSource::Track { track_id, .. } => track_id, @@ -574,6 +624,10 @@ impl EffectiveInputs for ModelInputs<'_> { self.fader_offsets.get(strip).copied().unwrap_or([None; 2]) } + fn vca_offset(&self, vca: usize) -> [Option; 2] { + self.vca_offsets.get(vca).copied().unwrap_or([None; 2]) + } + fn route_count(&self) -> usize { self.model.routes.len() } @@ -718,6 +772,11 @@ impl EffectiveInputs for LocalInputs<'_> { }) } + fn vca_offset(&self, vca: usize) -> [Option; 2] { + let id = &self.view.structure().vcas[vca].id; + crate::builtin_rows::fader_offsets(self.view.vca_automation(id)) + } + fn route_count(&self) -> usize { self.view.structure().routes.len() } diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index 0b07ebe16..9575cfefb 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -47,9 +47,11 @@ 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, - continued_mute_lanes, continued_route_rows, fader_automation, fader_row, - filter_statics_changed_under_automation, filter_under_automation, mute_automation, mute_row, - rendered_automation_changed, route_automation, route_gain_row, route_row, stored_segments, + continued_mute_lanes, continued_route_rows, fader_automation, fader_event_lanes, fader_row, + filter_statics_changed_under_automation, filter_under_automation, mute_automation, + mute_event_lanes, mute_row, rendered_automation_changed, route_automation, route_gain_row, + route_row, stored_segments, vca_mute_automation, vca_mute_row, vca_offset_automation, + vca_offset_row, }; 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 0c167dbcb..6037e3250 100644 --- a/crates/controls/src/mask.rs +++ b/crates/controls/src/mask.rs @@ -55,7 +55,11 @@ struct Target<'a> { /// of `into` drives is masked the same way (R17-6): the entry's offset is copied and the route's /// static `gain_db` is left as `into` holds it. A send's automated `mute` and matrix rows keep their /// static values as control values (a hand mute ORs with the curve; a matrix fallback is stored by -/// a hand edit), so they are copied. +/// a hand edit), so they are copied. A VCA lane whose `offset_db` a VCA-offset entry +/// ([`crate::vca_offset_row`]) of `into` drives is masked the same way (#1655 D5): the entry's +/// offset is copied and the VCA's static offset of that lane is left as `into` holds it. A VCA's +/// automated `mute` keeps its static (hand) mute as a control value, which render ORs with the +/// curve, so it is copied. /// /// After this call, `into` and `from` differ only in what is not a control value, and in the /// owners they do not share. @@ -135,7 +139,34 @@ pub fn mask_values(into: &mut SessionModel, from: &SessionModel) { route.follows_mute = source.follows_mute; } } + // The VCA lanes whose `offset_db` a VCA-offset entry of `into` drives (#1655 D5): their + // static offset is the fallback, not a control value, and stays as `into` holds it. + let kept: Vec<(session::StableId, [Option; 2])> = if into.automation.is_empty() { + Vec::new() + } else { + into.vcas + .iter() + .filter_map(|vca| { + let driven = crate::automation::vca_offset_automation(into, &vca.id); + let lanes = [ + driven[0].map(|_| vca.fader.left_db), + driven[1].map(|_| vca.fader.right_db), + ]; + (lanes != [None; 2]).then(|| (vca.id.clone(), lanes)) + }) + .collect() + }; into.vcas.clone_from(&from.vcas); + for (id, lanes) in kept { + if let Some(vca) = into.vcas.iter_mut().find(|vca| vca.id == id) { + if let Some(left) = lanes[0] { + vca.fader.left_db = left; + } + if let Some(right) = lanes[1] { + vca.fader.right_db = right; + } + } + } } /// The item of `items` that `matches`, trying position `index` first: two models of one session @@ -148,7 +179,8 @@ fn pair(items: &[T], index: usize, matches: impl Fn(&T) -> bool) -> Option<&T } /// Copy the hand-move offset of every entry on a row that takes one (a fader-row entry, -/// [`crate::fader_row`], and a send-gain entry, [`crate::route_gain_row`], R17-6) of `into` from +/// [`crate::fader_row`], a send-gain entry, [`crate::route_gain_row`], R17-6, and a VCA-offset +/// entry, [`crate::vca_offset_row`], #1655 D5) 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, or of the send's `gain_db`. An entry /// on another rendered row (a mute row, a send matrix row) carries no offset, so it is not masked. diff --git a/crates/controls/src/owner_edits.rs b/crates/controls/src/owner_edits.rs index a9616c730..cc8d2c2b1 100644 --- a/crates/controls/src/owner_edits.rs +++ b/crates/controls/src/owner_edits.rs @@ -11,7 +11,8 @@ //! R17-1 = A): a `fader_db` write clones the rendered entries that drive the strip's fader lanes //! with the strip, and [`crate::set`] on the owners model reads and writes them as it would on the //! whole model. A send's `gain_db` takes the same rule (root's ruling R17-6): a write to route row -//! 1 clones the rendered entry that drives it with the route. +//! 1 clones the rendered entry that drives it with the route; so does a VCA's `offset_db` (#1655 +//! D5): a write to VCA row 1 clones the rendered entries that drive its lanes with the VCA. use session::{OwnerSlots, SessionModel}; @@ -101,10 +102,30 @@ impl<'m> OwnerEdits<'m> { { self.take_route_gain_automation(route); } + if let Owner::Vca(vca) = &address.owner + && crate::automation::vca_offset_row(address) + { + self.take_vca_offset_automation(vca); + } crate::registry::set(&mut self.owners, address, value, ramp, sample_rate) .map_err(OwnerEditError::Control) } + /// Clone the rendered entries that drive VCA `vca`'s `offset_db` lanes into the owners model, + /// in ID order, unless they are there already (#1655 D5, R17-1 = A on the VCA offset). A scan + /// of the committed table, with an early out when it is empty. + fn take_vca_offset_automation(&mut self, vca: &session::StableId) { + if self.committed.automation.is_empty() { + return; + } + for entry in crate::automation::vca_offset_automation(self.committed, vca) + .into_iter() + .flatten() + { + insert_sorted(&mut self.owners.automation, entry, |entry| &entry.id); + } + } + /// Clone the rendered entries that drive strip `strip`'s fader lanes into the owners model, in /// ID order, unless they are there already (#1650, R17-1 = A). A scan of the committed table, /// with an early out when it is empty. diff --git a/crates/controls/src/registry.rs b/crates/controls/src/registry.rs index a850a9c24..84a4b5450 100644 --- a/crates/controls/src/registry.rs +++ b/crates/controls/src/registry.rs @@ -24,11 +24,13 @@ pub struct ControlRow { /// 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): the `fader_db` row, - /// since #1651 (root ruling R18-2) the `mute` row, and since #1654 a send's rows 1-6. On such - /// a lane the `fader_db` row's value is the driving entry's hand-move offset, not the static - /// fader, and so is a send's `gain_db` (R17-6); the `mute` rows' values stay the static (hand) + /// since #1651 (root ruling R18-2) the `mute` row, since #1654 a send's rows 1-6, and since + /// #1655 a VCA's `offset_db` and `mute`. On such a lane the `fader_db` row's value is the + /// driving entry's hand-move offset, not the static fader, and so are a send's `gain_db` + /// (R17-6) and a VCA's `offset_db` (#1655 D5); the `mute` rows' values stay the static (hand) /// mute, which render ORs with the curve (OQ1 = C), and a send matrix row's value is its - /// static fallback (automation wins). + /// static fallback (automation wins). A VCA ride does not mark its members' rows: they list + /// their own entries only. pub automated: bool, } @@ -249,7 +251,20 @@ pub fn owner_rows_in( effective.route_gate(index), ); } - OwnerSlot::Vca(index) => vca::push_rows(out, view.vca(index)), + OwnerSlot::Vca(index) => { + let vca = view.vca(index); + let entries = if model.automation.is_empty() { + vca::VcaEntries::default() + } else { + vca::VcaEntries { + offset: view.vca_automation(&vca.id), + // Which entry drives a mute lane is read from the committed table. + muted: crate::automation::vca_mute_automation(model, &vca.id) + .map(|entry| entry.is_some()), + } + }; + vca::push_rows(out, vca, entries); + } OwnerSlot::Session => session_rows::push_rows(out, model), OwnerSlot::None => {} } @@ -598,6 +613,12 @@ fn write( route::write(target, control, value) } (Owner::Vca(id), Section::Own) => { + // An automated VCA offset lane's value is its entry's hand-move offset (#1655 D5). + let static_sides = if control == vca::VCA_OFFSET_DB { + vca::write_offset(model, id, sides, value) + } else { + [true; 2] + }; let target = model .vcas .iter_mut() @@ -605,6 +626,7 @@ fn write( .ok_or(ControlError::AddressUnknown)?; sides .iter() + .filter(|&&side| static_sides[side.pick(0, 1)]) .try_for_each(|&side| vca::write(target, control, side, value)) } _ => Err(ControlError::AddressUnknown), diff --git a/crates/controls/src/vca.rs b/crates/controls/src/vca.rs index 012c94edd..bedc21756 100644 --- a/crates/controls/src/vca.rs +++ b/crates/controls/src/vca.rs @@ -2,17 +2,21 @@ //! through each member's effective fader and mute. use effect_contract::{ParameterLattice, ParameterUnit}; -use session::{FADER_MAXIMUM_DB, FADER_MINIMUM_DB, SessionModel, StableId, Vca}; +use session::{Automation, FADER_MAXIMUM_DB, FADER_MINIMUM_DB, SessionModel, StableId, Vca}; use crate::descriptor::{BASE, ControlDescriptor, Lanes, Motion}; use crate::error::ControlError; use crate::lane::{Side, is_on, toggle}; -use crate::registry::{ControlRow, Resolved, push_control}; +use crate::registry::{ControlRow, Resolved, push_control_automated}; use crate::{ControlKind, Family, LiveRampRow, Taper}; use session::{Owner, Section}; const OFFSET_DB: u32 = 1; const MUTE: u32 = 2; +/// The ID of the VCA's `offset_db` row: per lane, it takes a hand-move offset (#1655 D5). +pub(crate) const VCA_OFFSET_DB: u32 = OFFSET_DB; +/// The ID of the VCA's `mute` row (#1655 D4). +pub(crate) const VCA_MUTE: u32 = MUTE; /// One row of the VCA table. pub(crate) struct VcaRow { @@ -72,17 +76,44 @@ pub(crate) static ROWS: [VcaRow; 2] = [ }, ]; -/// Append every control row of one VCA. -pub(crate) fn push_rows(out: &mut Vec, vca: &Vca) { +/// The stored automation entries that drive one VCA's rows, per lane (`[left, right]`, #1655): +/// `offset` the `offset_db` entries ([`crate::vca_offset_automation`], or +/// [`crate::ModelView::vca_automation`]), `muted` whether a `mute` entry drives the lane. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct VcaEntries<'a> { + pub(crate) offset: [Option<&'a Automation>; 2], + pub(crate) muted: [bool; 2], +} + +/// The committed value of `row` on `side`: the row's model field, except that an automated +/// `offset_db` lane reads its entry's hand-move offset (#1655 D5; the static offset is the lane's +/// fallback, not heard). +fn value(row: &VcaRow, vca: &Vca, side: Side, offset: [Option<&Automation>; 2]) -> f32 { + match side.pick(offset[0], offset[1]) { + Some(entry) if row.id == OFFSET_DB => { + side.pick(entry.offset.left_db, entry.offset.right_db) + } + _ => (row.read)(vca, side), + } +} + +/// Append every control row of one VCA. A lane an entry drives lists `automated = true` (#1650 D4 +/// on the VCA): an automated `offset_db` lane lists its entry's offset as its value, an automated +/// `mute` lane its static (hand) mute, which render ORs with the curve (D5). +pub(crate) fn push_rows(out: &mut Vec, vca: &Vca, entries: VcaEntries<'_>) { let owner = Owner::Vca(vca.id.clone()); for row in &ROWS { - push_control( + push_control_automated( out, &owner, &Section::Own, row.id, &row.descriptor, - |side| (row.read)(vca, side), + |side| value(row, vca, side, entries.offset), + |side| match row.id { + OFFSET_DB => side.pick(entries.offset[0], entries.offset[1]).is_some(), + _ => side.pick(entries.muted[0], entries.muted[1]), + }, ); } } @@ -102,11 +133,45 @@ pub(crate) fn resolve( .iter() .find(|row| row.id == control) .ok_or(ControlError::AddressUnknown)?; + let offset = if control == OFFSET_DB && !model.automation.is_empty() { + crate::automation::vca_offset_automation(model, id) + } else { + [None; 2] + }; Ok(Resolved::stored(row.descriptor, |side| { - (row.read)(vca, side) + value(row, vca, side, offset) })) } +/// Write `value` as the hand-move offset of each side in `sides` of VCA `vca`'s `offset_db` that a +/// rendered entry of `model` drives (#1655 D5), and return the sides no entry drives, which keep +/// the static offset write. The caller has run [`crate::check`] with the row's own domain, +/// `[-144, 24]` dB, which is the offset domain. +pub(crate) fn write_offset( + model: &mut SessionModel, + vca: &StableId, + sides: &[Side], + value: f32, +) -> [bool; 2] { + let entries = if model.automation.is_empty() { + [None; 2] + } else { + crate::automation::vca_offset_automation(model, vca) + }; + let ids = entries.map(|entry| entry.map(|entry| entry.id.clone())); + let mut static_sides = [false; 2]; + for &side in sides { + let Some(id) = side.pick(&ids[0], &ids[1]) else { + static_sides[side.pick(0, 1)] = true; + continue; + }; + if let Some(entry) = model.automation.iter_mut().find(|entry| entry.id == *id) { + *side.pick(&mut entry.offset.left_db, &mut entry.offset.right_db) = value; + } + } + static_sides +} + /// Write one of a VCA's rows on `side`. pub(crate) fn write( vca: &mut Vca, diff --git a/crates/controls/src/view.rs b/crates/controls/src/view.rs index 33cbe2d07..21e6f6077 100644 --- a/crates/controls/src/view.rs +++ b/crates/controls/src/view.rs @@ -141,6 +141,28 @@ impl<'a> ModelView<'a> { }) } + /// The rendered stored automation entries that drive VCA `vca`'s `offset_db`, per lane + /// (`[left, right]`, [`crate::vca_offset_automation`]), each the edited entry when the view + /// holds one (#1655 D5, R17-1): a hand move on an automated VCA offset writes the driving + /// entry's offset, so the binding of the VCA's `offset_db` row and each reached member lane's + /// `S` read the entry here. Which entry drives a lane is read from the committed table, and + /// only its offset from the edit, as for [`Self::fader_automation`]. + #[must_use] + pub fn vca_automation(&self, vca: &StableId) -> [Option<&'a Automation>; 2] { + if self.structure.automation.is_empty() { + return [None; 2]; + } + let lanes = crate::automation::vca_offset_automation(self.structure, vca); + if self.is_whole() { + return lanes; + } + lanes.map(|entry| { + entry.map(|entry| { + find(&self.owners.automation, &entry.id, |held| &held.id).unwrap_or(entry) + }) + }) + } + /// The dense model the view stands for: a clone of the committed model with the edited owners /// in place. Linear in the session; for tests and for a caller that has no view-shaped code. #[must_use] diff --git a/crates/controls/tests/automation.rs b/crates/controls/tests/automation.rs index d1f716a90..3c0f9bd4c 100644 --- a/crates/controls/tests/automation.rs +++ b/crates/controls/tests/automation.rs @@ -1150,3 +1150,441 @@ fn the_mask_copies_a_send_offset_and_keeps_its_fallback() { assert_eq!(route.channel_matrix.lr, 0.5); } } + +const VCA_OFFSET_DB: u32 = 1; +const VCA_MUTE: u32 = 2; + +fn vca_own(vca: &str, control: u32, lane: Lane) -> ControlAddress { + address(Owner::Vca(id(vca)), Section::Own, control, lane) +} + +fn vca(name: &str, offset: [f32; 2], members: &[&str]) -> session::Vca { + session::Vca { + id: id(name), + fader: session::DualMonoFader { + left_db: offset[0], + right_db: offset[1], + left_mute: false, + right_mute: false, + }, + members: members.iter().map(|member| id(member)).collect(), + } +} + +/// The fixture with VCA `riders` (over `vocal`) ridden on its left `offset_db` lane (`ride`, hand +/// offset `0`) and stepped on both `mute` lanes (`steps`). +fn ridden_vca() -> SessionModel { + let mut model = effects_fixture(); + model.automation = vec![ + one( + "ride", + vca_own("riders", VCA_OFFSET_DB, Lane::Left), + ParameterUnit::Db, + -6.0, + ), + one( + "steps", + vca_own("riders", VCA_MUTE, Lane::Both), + ParameterUnit::Linear, + 1.0, + ), + ]; + model +} + +/// #1655 D5, D6: a VCA's rows 1 and 2 render ([`controls::automation_row_renders`]), and its +/// `offset_db` takes a per-lane hand-move offset in the fader domain: a `both` entry may hold two +/// different offsets (unlike a send's shared gain), a lane the entry does not drive holds `+0.0`, +/// an offset outside `[-144, 24]` dB is `control.domain`, and the `mute` row takes none. +/// +/// Red if the VCA rows stay masked from rendering, if the VCA offset takes no offset (the accepted +/// cases are refused), or if the lane or domain rule is missing on the VCA row. +#[test] +fn vca_rows_render_and_take_a_per_lane_offset() { + use controls::{automation_row_renders, fader_row, vca_mute_row, vca_offset_row}; + for lane in [Lane::Left, Lane::Right, Lane::Both] { + let offset = vca_own("riders", VCA_OFFSET_DB, lane); + let mute = vca_own("riders", VCA_MUTE, lane); + assert!(vca_offset_row(&offset) && automation_row_renders(&offset)); + assert!(vca_mute_row(&mute) && automation_row_renders(&mute)); + assert!(!vca_offset_row(&mute) && !vca_mute_row(&offset)); + assert!(!fader_row(&offset), "a VCA ride is not a strip's fader"); + } + let ride = |name: &str, lane: Lane| { + one( + name, + vca_own("riders", VCA_OFFSET_DB, lane), + ParameterUnit::Db, + -3.0, + ) + }; + for accepted in [ + with_offset(ride("both", Lane::Both), 3.0, -6.0), + with_offset(ride("left", Lane::Left), 24.0, 0.0), + with_offset(ride("right", Lane::Right), 0.0, -144.0), + ] { + let name = accepted.id.as_str().to_owned(); + assert_eq!(judged(vec![accepted]), [], "{name}"); + } + let refused = [ + ( + with_offset(ride("left-right", Lane::Left), 0.0, 1.0), + "control.automation.offset", + ".offset.right_db", + ), + ( + with_offset(ride("above", Lane::Both), 0.0, 24.5), + "control.domain", + ".offset.right_db", + ), + ( + with_offset( + one( + "mute", + vca_own("riders", VCA_MUTE, Lane::Left), + ParameterUnit::Linear, + 1.0, + ), + 1.0, + 0.0, + ), + "control.automation.offset", + ".offset.left_db", + ), + ]; + for (entry, code, suffix) in refused { + let name = entry.id.as_str().to_owned(); + assert_eq!( + judged(vec![entry]), + [(code, format!("$.automation[id={name}]{suffix}"))], + "{name}" + ); + } +} + +/// #1655 D5 and R17-1 at the controls level: a `SET_CONTROL` on an automated VCA `offset_db` lane +/// writes the driving entry's offset of that lane and leaves the static offset (the fallback); a +/// lane no entry drives keeps the static write; `get` and the list report the offset with +/// `automated = true`. A hand VCA mute on an automated mute lane stays the static (hand) mute, +/// listed `automated`. The member's own rows are not marked. The in-place path +/// ([`controls::OwnerEdits`], read through [`controls::ModelView`]) gives the dense model, the +/// dense rows and the dense effective `S`. +/// +/// Red if a hand move on an automated VCA offset writes the static offset (acked, never heard), if +/// it writes the lane the entry does not drive, if the list hides the offset or the flag, if a VCA +/// ride marks its member's own fader as automated, or if the in-place path does not clone the +/// entry with the VCA. +#[test] +fn a_hand_move_on_an_automated_vca_offset_writes_its_offset() { + use controls::{ + LocalEffective, ModelView, MonitorOverlay, OwnerEdits, effective, get, owner_index, + owner_rows_in, owner_rows_with, set, + }; + let committed = ridden_vca(); + let left = vca_own("riders", VCA_OFFSET_DB, Lane::Left); + let right = vca_own("riders", VCA_OFFSET_DB, Lane::Right); + let mute = vca_own("riders", VCA_MUTE, Lane::Left); + let riders = |model: &SessionModel| model.vcas[0].clone(); + let static_left = riders(&committed).fader.left_db; + + let mut whole = committed.clone(); + set(&mut whole, &left, 3.0, None, RATE).expect("left"); + set(&mut whole, &right, -1.0, None, RATE).expect("right"); + set(&mut whole, &mute, 0.0, None, RATE).expect("mute"); + assert_eq!(whole.automation[0].offset.left_db, 3.0); + assert_eq!( + whole.automation[0].offset.right_db.to_bits(), + 0.0f32.to_bits() + ); + assert_eq!( + riders(&whole).fader.left_db.to_bits(), + static_left.to_bits() + ); + assert_eq!(riders(&whole).fader.right_db, -1.0); + assert!( + !riders(&whole).fader.left_mute, + "the hand mute is the static mute" + ); + let overlay = MonitorOverlay::default(); + assert_eq!(get(&whole, &overlay, &left, RATE), Ok(3.0)); + let left_row = common::row_of(&whole, &left); + assert!(left_row.automated && left_row.value == 3.0); + let right_row = common::row_of(&whole, &right); + assert!(!right_row.automated && right_row.value == -1.0); + let mute_row = common::row_of(&whole, &mute); + assert!(mute_row.automated && mute_row.value == 0.0); + let member = common::row_of(&whole, &strip_builtin("vocal", FADER_DB, Lane::Left)); + assert!(!member.automated, "the member's own fader is not automated"); + + // The in-place path: the same writes on the owners model. + let mut edits = OwnerEdits::new(&committed); + edits.set(&left, 3.0, None, RATE).expect("in-place left"); + edits.set(&right, -1.0, None, RATE).expect("in-place right"); + edits.set(&mute, 0.0, None, RATE).expect("in-place mute"); + let (owners, slots) = edits.finish(); + assert_eq!(owners.automation.len(), 1, "the ride alone is cloned"); + assert_eq!(slots.automation, [(0, 0)]); + let view = ModelView::edited(&committed, &owners); + assert_eq!(view.to_model(), whole); + assert_eq!( + view.vca_automation(&id("riders"))[0].map(|entry| entry.offset.left_db), + Some(3.0) + ); + let owner = owner_index(&committed, &Owner::Vca(id("riders"))).expect("riders"); + let dense = effective(&whole, &overlay); + let (mut dense_rows, mut view_rows) = (Vec::new(), Vec::new()); + owner_rows_with(&whole, &dense, &overlay, owner, RATE, &mut dense_rows); + owner_rows_in(view, &dense, &overlay, owner, RATE, &mut view_rows); + assert_eq!(view_rows, dense_rows); + // `S` of the member reads the edited offset on the in-place path too. + let local = LocalEffective::new(view, &overlay); + let vocal = controls::strip_index(&whole, &id("vocal")).expect("vocal"); + assert_eq!( + local.strip(vocal).offsets_sum.map(f64::to_bits), + dense.strips[vocal].offsets_sum.map(f64::to_bits) + ); + assert_eq!( + dense.strips[vocal].offsets_sum[0], 3.0, + "S holds the hand offset" + ); +} + +/// #1655 mask treatment: [`controls::mask_values`] copies a VCA-offset entry's offset as a control +/// value and keeps the static offset of an automated VCA lane as structure (its fallback), while +/// the lane no entry drives, the VCA mutes (a hand mute ORs) and the members stay control values. +/// +/// Red if a VCA offset change is seen as structure (a live hand move would rebuild), or if a change +/// of an automated VCA lane's static offset is hidden as a value (it would never rebuild). +#[test] +fn the_mask_copies_a_vca_offset_and_keeps_its_fallback() { + use controls::mask_values; + let mut from = ridden_vca(); + from.automation[0].offset.left_db = 2.0; + from.vcas[0].fader.left_db = -9.0; + from.vcas[0].fader.right_db = -9.0; + from.vcas[0].fader.left_mute = false; + from.vcas[0].members.push(id("guitar")); + let mut into = ridden_vca(); + let kept = into.vcas[0].fader.left_db; + mask_values(&mut into, &from); + assert_eq!(into.automation[0].offset.left_db, 2.0); + assert_eq!(into.vcas[0].fader.left_db.to_bits(), kept.to_bits()); + assert_eq!(into.vcas[0].fader.right_db, -9.0); + assert!(!into.vcas[0].fader.left_mute); + assert_eq!(into.vcas[0].members, from.vcas[0].members); +} + +/// #1655 batch 19 amendment, event lanes: [`controls::fader_event_lanes`] holds on a lane whose own +/// fader is automated or that an automated VCA `offset_db` lane reaches (directly or through a +/// nested VCA; lane `l` to lane `l`), and [`controls::mute_event_lanes`] by the same rule on the +/// mute rows. A VCA ride reaches no own-entry reader: [`controls::fader_automation`] and the +/// member's `fader_offset` stay empty. +/// +/// Red if the predicate misses a VCA ride (the member would render no VCA curve), takes it across +/// lanes or rows (an offset ride making a mute event lane), ignores nesting, or if a VCA ride leaks +/// into a member's own-entry reader. +#[test] +fn event_lanes_take_the_own_entry_and_every_reaching_vca_ride() { + use controls::{ + MonitorOverlay, effective, fader_automation, fader_event_lanes, mute_event_lanes, + }; + let mut model = effects_fixture(); + model.vcas = vec![ + vca("outer", [0.0, 0.0], &["riders"]), + vca("riders", [0.0, 0.0], &["vocal"]), + ]; + let lanes = |model: &SessionModel, strip: &str| { + let strip = id(strip); + let reach = model.vca_reach_of(&strip); + ( + fader_event_lanes(model, &strip, &reach), + mute_event_lanes(model, &strip, &reach), + ) + }; + assert_eq!(lanes(&model, "vocal"), ([false; 2], [false; 2])); + + // A VCA offset ride on the right lane: vocal's right fader lane, nothing else. + model.automation = vec![one( + "ride", + vca_own("riders", VCA_OFFSET_DB, Lane::Right), + ParameterUnit::Db, + -6.0, + )]; + assert_eq!(lanes(&model, "vocal"), ([false, true], [false; 2])); + assert_eq!(lanes(&model, "guitar"), ([false; 2], [false; 2])); + assert_eq!(fader_automation(&model, &id("vocal")), [None, None]); + let strip = effective(&model, &MonitorOverlay::default()).strips[0]; + assert_eq!(strip.fader_offset, [None, None]); + + // A nested VCA's mute steps on the left lane, and the strip's own left fader ride. + model.automation.push(one( + "steps", + vca_own("outer", VCA_MUTE, Lane::Left), + ParameterUnit::Linear, + 1.0, + )); + model.automation.push(one( + "own", + strip_builtin("vocal", FADER_DB, Lane::Left), + ParameterUnit::Db, + -6.0, + )); + assert_eq!(lanes(&model, "vocal"), ([true, true], [true, false])); + assert_eq!(lanes(&model, "guitar"), ([false; 2], [false; 2])); + + // The strip's own mute entry alone. + model.automation = vec![one( + "own-mute", + strip_builtin("guitar", MUTE, Lane::Right), + ParameterUnit::Linear, + 1.0, + )]; + assert_eq!(lanes(&model, "guitar"), ([false; 2], [false, true])); +} + +/// #1655 D5 (root's ruling of 2026-10-14): with equal rendered entries, +/// [`controls::rendered_automation_changed`] is `true` when the strips an automated VCA reaches +/// differ (a member added or removed, a nesting edit that reaches another strip), and `false` for a +/// reach change of a VCA no rendered entry drives, a nesting edit that reaches no other strip, and +/// a hand offset change. +/// +/// Red if an automated VCA's membership edit commits live (no event state for the new pair), or if +/// a non-automated VCA's edit or a hand move rebuilds (D15-6's live set narrowed beyond the +/// ruling). +#[test] +fn a_reach_change_of_an_automated_vca_rebuilds() { + use controls::rendered_automation_changed; + let mut current = ridden_vca(); + current.automation.truncate(1); + current.vcas.push(vca("plain", [0.0, 0.0], &["guitar"])); + let changed = |edit: &dyn Fn(&mut SessionModel)| { + let mut next = current.clone(); + edit(&mut next); + rendered_automation_changed(¤t, &next) + }; + assert!(!changed(&|_| {})); + // The automated VCA: a member added, a member removed, a nested VCA that reaches guitar. + assert!(changed(&|next| next.vcas[0].members.push(id("guitar")))); + assert!(changed(&|next| next.vcas[0].members.clear())); + assert!(changed(&|next| next.vcas[0].members.push(id("plain")))); + // A VCA no entry drives: its members, a parent over the automated VCA (riders' own reach is + // unchanged), and a hand offset change. + assert!(!changed(&|next| next.vcas[1].members.push(id("vocal")))); + assert!(!changed(&|next| next.vcas[1].members.push(id("riders")))); + assert!(!changed(&|next| next.automation[0].offset.left_db = 3.0)); + // The mute row counts too. + current.automation = vec![one( + "steps", + vca_own("riders", VCA_MUTE, Lane::Both), + ParameterUnit::Linear, + 1.0, + )]; + let mut next = current.clone(); + next.vcas[0].members.push(id("guitar")); + assert!(rendered_automation_changed(¤t, &next)); +} + +/// #1655 D2 (root's ruling of 2026-10-14, class B order): on a member lane, `S` is the lane's own +/// hand offset, then the static offsets of the reaching VCAs whose lane is not automated, then the +/// hand offsets of the automated ones, each group in ascending VCA ID. The values are chosen so +/// the two orders give different `f64` bits: VCA `a` (static `1`), `b` (automated, hand +/// `2^-60`), `c` (static `-1`). `fader_db` stays the static composition. With no VCA offset +/// automation (none, or a VCA mute entry only) every composed value keeps the bits of the +/// ascending-ID sum, and the in-place path agrees with the dense one. +/// +/// Red if the automated VCA's static offset (its fallback) enters `S`, if the hand offsets are +/// summed in ID order among the statics, or if a session without VCA offset automation moves a +/// bit of `S` or of the effective fader. +#[test] +fn the_offset_sum_puts_the_static_vca_offsets_first() { + use controls::{LocalEffective, ModelView, MonitorOverlay, effective}; + use session::VcaSum; + let tiny = f32::from_bits(0x2180_0000); // 2^-60 + assert_eq!(f64::from(tiny).to_bits(), 0x3C30_0000_0000_0000); // 2^-60 + let mut model = effects_fixture(); + model.vcas = vec![ + vca("a", [1.0, 1.0], &["vocal"]), + vca("b", [-12.0, 5.0], &["vocal"]), + vca("c", [-1.0, -1.0], &["vocal"]), + ]; + let overlay = MonitorOverlay::default(); + let vocal = controls::strip_index(&model, &id("vocal")).expect("vocal"); + let ascending = |values: [f32; 3]| { + let mut sum = VcaSum::new(); + values.into_iter().for_each(|value| sum.add(value)); + sum + }; + + // No VCA automation: S and the fader are the ascending-ID sum, bit for bit. + let base = effective(&model, &overlay).strips[vocal]; + for (lane, b) in [-12.0, 5.0].into_iter().enumerate() { + let sum = ascending([1.0, b, -1.0]); + let own = &model.tracks[vocal].fader; + let own = [own.left_db, own.right_db][lane]; + assert_eq!( + base.offsets_sum[lane].to_bits(), + sum.sum().unwrap().to_bits() + ); + assert_eq!(base.fader_db[lane].to_bits(), sum.compose(own).to_bits()); + } + // A VCA mute entry alone moves no bit. + let mut muted = model.clone(); + muted.automation = vec![one( + "steps", + vca_own("b", VCA_MUTE, Lane::Both), + ParameterUnit::Linear, + 1.0, + )]; + assert_eq!(effective(&muted, &overlay).strips[vocal], base); + + // `b` ridden on the left lane with hand offset 2^-60. + let mut ridden = model.clone(); + ridden.automation = vec![with_offset( + one( + "ride", + vca_own("b", VCA_OFFSET_DB, Lane::Left), + ParameterUnit::Db, + -6.0, + ), + tiny, + 0.0, + )]; + let strip = effective(&ridden, &overlay).strips[vocal]; + let d2 = ascending([1.0, -1.0, tiny]).sum().unwrap(); + let id_order = ascending([1.0, tiny, -1.0]).sum().unwrap(); + assert_ne!( + d2.to_bits(), + id_order.to_bits(), + "the values tell the orders apart" + ); + assert_eq!(strip.offsets_sum[0].to_bits(), d2.to_bits()); + assert_eq!( + strip.offsets_sum[1].to_bits(), + base.offsets_sum[1].to_bits(), + "the right lane is not ridden" + ); + assert_eq!( + strip.fader_db.map(f32::to_bits), + base.fader_db.map(f32::to_bits) + ); + let local = LocalEffective::new(ModelView::whole(&ridden), &overlay).strip(vocal); + assert_eq!(local, strip); + + // With the member's own fader ridden too, its hand offset comes first. + ridden.automation.push(with_offset( + one( + "own", + strip_builtin("vocal", FADER_DB, Lane::Left), + ParameterUnit::Db, + -6.0, + ), + 0.5, + 0.0, + )); + let strip = effective(&ridden, &overlay).strips[vocal]; + let mut sum = VcaSum::new(); + [0.5, 1.0, -1.0, tiny] + .into_iter() + .for_each(|value| sum.add(value)); + assert_eq!(strip.offsets_sum[0].to_bits(), sum.sum().unwrap().to_bits()); +} From 13abb0cc00e9f932da33d2962f2e2d0feda3c0ae Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 13:17:53 +0000 Subject: [PATCH 08/36] Gate the owed discharge and delete the conservative level-move flag (#1711 F3, F4) G4: an exact unmute that frees a held lane discharges the owed move (C ABI and every host-core form; red with the line deleted). D2: LevelMoveWindow and the B17-R14/R15 TransitionFallback term are deleted, with their doc text in abi.rs, the C header, C_ABI_V1_QUALIFICATION.md and both d.ts files (the #1713 text stays). Every G1, G2 and R19-1 case now asserts OUTCOME_EXACT with no fallback on both hosts. Co-Authored-By: Claude Opus 5.5 --- ...ves-own-length-across-a-reposition-swap.md | 52 ++++ crates/builtins-compiler/src/lib.rs | 4 + crates/capi/include/miso_engine_v1.h | 6 +- crates/capi/src/abi.rs | 7 +- crates/capi/src/runtime/live_tests.rs | 252 ++++++++++-------- crates/control-plane/src/compile.rs | 1 - crates/control-plane/src/control.rs | 164 +----------- crates/control-plane/src/plan.rs | 2 +- crates/controls/src/automation.rs | 9 +- .../tests/stored_fader_automation.rs | 52 ++++ docs/C_ABI_V1_QUALIFICATION.md | 6 +- hosts/host-web/tests/control_parity.rs | 130 ++------- .../miso-engine-v1-audio-worklet-host.d.ts | 7 +- sdk/src/browser/shipped-host.d.ts | 7 +- 14 files changed, 290 insertions(+), 409 deletions(-) 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 663e3b079..494aada05 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 @@ -390,3 +390,55 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af host-core `stored_fader_automation` (33), `stored_mute_automation` (13), `successor_swap` (140), `session_seek`, `browser_successor`, capi lib (211), host-web `control_parity` (31): all green. Realtime policy: 168 regions (unchanged). + +## Decision record: F3 and F4 (batch 19) + +- **G4 shipped (A1): the owed discharge is kept and gated.** The line `self.owed = false` in + `HeldRampObligation::block` (`crates/builtins-compiler/src/lib.rs`) now has a doc comment that + names the gate. Gates: `an_exact_unmute_that_frees_a_held_lane_discharges_the_owed_move` on the + C ABI (`crates/capi/src/runtime/live_tests.rs`, quantum 128, hand mutes as in A1) and in + `crates/host-core/tests/stored_fader_automation.rs` (`acted`, every one of `FORMS`: Simd8 on + AVX2, Simd4, the between-render forms and the scalar oracle; fader length 20 ms = 960). Both + compare with a rig that takes only the second hold, from block 9 (sample 1,152) on. Red with the + line deleted (run once, restored): C ABI first difference block 17; host-core first difference + at sample 2,248 (`E`). +- **D2 shipped (A4).** `crates/control-plane/src/control.rs`: deleted the `level_moves` field, + `LevelMoveWindow`, `may_ramp`, `fold_level_move`, `rebuild_level_ramp`, `rebuild_moves_a_level`, + the B17-R14 comment, `automates`, `in_flight`, `level_fallback`, `moved`, `landing`, + `rebuild_level`, both folds and the live commit's `level_ramp`. The outcome is now the #1713 + six-word flag alone (a transaction rebuild with `follow_into_six_word`; a discontinuity is + `Exact`). `reservation.set_outcome` and `ObservedReservation::set_outcome` stay (#1713); the doc + of the latter names #1713 only. `compile.rs`: `level_moves: None` deleted. The B17-R14 clause + is removed from `abi.rs`, `miso_engine_v1.h`, `docs/C_ABI_V1_QUALIFICATION.md` and the two d.ts + files (the #1713 clause kept); `crates/controls/src/automation.rs` no longer names the window. +- **Tests deleted:** `a_reposition_swap_while_a_level_move_ramps_reports_a_fallback`, + `a_second_reposition_inside_a_stretched_landing_reports_a_fallback` (C ABI) and + `a_mute_curve_rebuild_while_a_level_move_ramps_completes_exactly_on_both_hosts` (host-web). +- **Outcome checks (G1, G2, R19-1).** A new helper `assert_exact` (the watermark covers the newest + revision, its last advance reads `OUTCOME_EXACT` alone, transition-fallback count 0) is called by + every case of `a_level_move_across_a_table_change_keeps_its_length` (28 cases) and of + `moved_target_case` (G2, its case (c), and R19-1). Host-web: the B17-R14 parity test is now + `a_hand_move_in_flight_across_a_table_only_rebuild_completes_exactly_alike_on_both_hosts` (no + move and a hand move in flight: `EXACT`, no fallback on either host, same bits and watermark at + the four rates), and `a_moved_target_defers_its_jump_alike_on_both_hosts` asserts the same. +- **Two rebuilds 5 blocks apart (A3): outcome only.** + `table_only_rebuilds_around_a_level_move_complete_exactly` (C ABI): with no move (R1, R2) and + with a 500-sample VCA move heard 2 blocks, each rebuild reports `OUTCOME_EXACT` with no + fallback, every block is allocation-free, every successor is adopted. Bits are not compared: + the split tables' values differ from the ride in `f32` rounding, so the adopting cell jumps + over `J` where a fresh plan does not (F2 record: 7.7e-5 to 1.7e-4). A bit claim needs a + phase-2 decision on rounding-only differences at adoption. The no-move case is also the + "a no-live-move rebuild stays EXACT" check. +- **F2 note fixed.** The B17-R13 step gate's doc no longer names "a long landing"; it says the + step one sample after the swap must jump at its own sample (B17-R13's 8.6e-2 measurement kept + as history). The #1713 test's doc and comment no longer name the level-move window ("the flag + marks its own candidate only"). +- **Departure: no G4 parity case in `control_parity.rs`.** The coordinator's tranche scoped G4 to + host-core (Simd8, Simd4) and the C ABI; the host-web parity leg of A3 G4 is a phase-2 + placeholder. +- Gates run: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` on control-plane, + capi, host-core, builtins-compiler, host-web, controls; `cargo check --locked --all-targets` (no + features) on the first five; tests: control-plane (`test-support`), capi lib (211 passed, 1 + ignored), host-core `stored_fader_automation` (34), `stored_mute_automation` (13), + `successor_swap` (140), host-web `control_parity` (30): all green. `scripts/check-capi-abi.sh`, + `check-sdk-generated.sh`, `check-sdk-types.sh`: ok. Realtime policy: 168 regions (unchanged). diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index b306e0a52..ed3e6af7e 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -2075,6 +2075,10 @@ impl HeldRampObligation { } Some(_) if due && self.owed => (0, Some(0), false), Some(_) => { + // A live write that freed a held stage at this entry (an exact unmute) discharges + // the move the hold owed; a stale `owed` would land a move over `J` at a later + // hold's end with nothing remembered there (#1711 G4, + // `an_exact_unmute_that_frees_a_held_lane_discharges_the_owed_move`). self.owed = false; (0, None, held_through && !due) } diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index 4e3043ee5..4fc865294 100644 --- a/crates/capi/include/miso_engine_v1.h +++ b/crates/capi/include/miso_engine_v1.h @@ -180,10 +180,8 @@ extern "C" { * over the revisions the last advance covered of MISO_ENGINE_V1_OUTCOME_EXACT, * MISO_ENGINE_V1_OUTCOME_TRANSITION_FALLBACK and MISO_ENGINE_V1_OUTCOME_SUPERSEDED. * 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), 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; #1713 removes it). The host + * transition, 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; #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 8f0087200..202d2a5b5 100644 --- a/crates/capi/src/abi.rs +++ b/crates/capi/src/abi.rs @@ -93,10 +93,9 @@ pub const FEATURE_MASK: u64 = FEATURE_IMMUTABLE_SESSION /// Watermark outcome flag: the covered revisions completed exactly as committed. pub const OUTCOME_EXACT: u64 = 1; /// Watermark outcome flag: a warm successor could not adopt, and the covered revisions completed -/// through the transition it fell back to, 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; #1713 removes it). +/// through the transition it fell back to, 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; +/// #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 e49e21c77..21b7220dc 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -9063,11 +9063,8 @@ mod set_control { /// heard 1, 12, 24, 26 and 30 blocks before it (3,872, 2,464, 928, 672 and 160 samples /// left, the last three shorter than the fader length `J` = 960). Every block of the /// rebuilt rig is bit-equal to the uninterrupted engine's over 44 blocks, allocation-free, - /// and the successor is adopted. - /// - /// The watermark outcome is not asserted here: until D2 deletes the B17-R14 window, the - /// heard 24, 26 and 30 cases still report `OUTCOME_TRANSITION_FALLBACK` - /// (`a_reposition_swap_while_a_level_move_ramps_reports_a_fallback`). + /// the successor is adopted, and the rebuild's watermark advance reports `OUTCOME_EXACT` + /// with no transition fallback (#1711 D2: the B17-R14 flag is deleted). /// /// Test value: red if the adoption rule compares the curve with the stage's fader gain /// rather than its ramp's target (#1711 F0: the held grid events only remember a gain, so @@ -9142,6 +9139,7 @@ mod set_control { let (first, _) = lockstep(&mut rigs, 44); assert_eq!(rigs[0].summary().3, 0, "{label}: the successor is adopted"); assert_eq!(first, None, "{label}: first difference (block, sample)"); + assert_exact(&rigs[0], label); } } } @@ -9182,8 +9180,9 @@ mod set_control { /// /// Test value: red if the adoption skips a step at the block's first sample (it compares /// the curve where the move's ramp ends and lands over the ramp's remaining length, not - /// over `J` at the step; 8.6e-2, checked once), or if a long landing runs over the whole - /// block, step included (the step one sample after the swap; 8.6e-2, checked once). + /// over `J` at the step; 8.6e-2, checked once), or if the step one sample after the swap + /// does not jump at its own sample (B17-R13 measured 8.6e-2 when a landing ran over the + /// whole block, step included; #1711 D1 removed that landing). #[test] fn a_step_at_a_reposition_swap_jumps_over_the_fader_length() { /// The session's fader jump length `J`. @@ -9302,7 +9301,8 @@ mod set_control { /// the move's first free sample, -5 dB to 6,000, then -3 dB; with later moves, the /// rebuild's own table) and given the same moves at the same blocks: from sample `from` /// on every sample is bit-equal over 44 blocks after the swap, every block is - /// allocation-free, and the successor is adopted. + /// allocation-free, the successor is adopted, and every revision completed exactly (the + /// rebuild's and the later moves': `OUTCOME_EXACT`, no transition fallback). fn moved_target_case(hand: bool, heard: usize, later: &[(u64, f32, u32)], from: u64) { const MOVE: u32 = 4_000; bench_support::alloc::assert_installed(); @@ -9395,6 +9395,7 @@ mod set_control { y.push(r.step()); } assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); + assert_exact(&a, label); let quantum = a.quantum as u64; let first = x.iter().zip(&y).enumerate().find_map(|(index, (x, y))| { let start = (4 + index as u64) * quantum; @@ -9451,122 +9452,76 @@ mod set_control { (watermark.outcome_flags, watermark.transition_fallback_count) } - /// B17-R14 (#1648 split, #1711). A table-only rebuild of the riding fader while a live - /// level move may still ramp completes as the transition fallback: a VCA move with its - /// own 4,000 samples heard 24, 26 and 30 blocks (928, 672 and 160 samples left, landed - /// over `J` = 960), and a VCA move committed inside a rebuild, heard 12 blocks, then a - /// table-only rebuild (the rebuild path's moves). The watermark advance that covers the - /// rebuild reports `OUTCOME_TRANSITION_FALLBACK`, not `OUTCOME_EXACT`. A table-only - /// rebuild with no move, and one after a move whose ramp has ended (heard 40 blocks), - /// report `OUTCOME_EXACT`. + /// The watermark covers `rig`'s newest committed revision, its last advance reports + /// `OUTCOME_EXACT` alone, and no revision of the rig completed as the transition fallback. + fn assert_exact(rig: &Rig, label: &str) { + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + assert_eq!( + watermark.revision, + rig.summary().0, + "{label}: the watermark covers the newest revision" + ); + assert_eq!( + outcome(rig), + (crate::abi::OUTCOME_EXACT, 0), + "{label}: the outcome flags and the transition-fallback count" + ); + } + + /// #1711 A3 (D2). A table-only rebuild of the riding fader with no live move, and two + /// table-only rebuilds 5 blocks apart while a live VCA move of 500 samples ramps (heard 2 + /// blocks before the first; the second comes after the move's own ramp has ended): each + /// rebuild's watermark advance reports `OUTCOME_EXACT` with no transition fallback, every + /// block is allocation-free, and each successor is adopted. Bits are not compared: the + /// split tables' values differ from the ride's in `f32` rounding, so the adopting cell + /// jumps over `J` where a fresh plan does not (the F2 decision record). /// - /// Test value: red if the control plane does not mark the candidate (the stretched move - /// then completes as exact, checked once with the `set_outcome` line removed), if a live - /// move or a rebuild's level change is not tracked, or if a rebuild with no move in - /// flight is marked (the flag must stay narrow). + /// Test value: red if a Reposition rebuild with a level move in flight, or one with no + /// move, completes as the transition fallback (the B17-R14 flag, or any successor). #[test] - fn a_reposition_swap_while_a_level_move_ramps_reports_a_fallback() { - let fallback = crate::abi::OUTCOME_TRANSITION_FALLBACK; - let exact = crate::abi::OUTCOME_EXACT; + fn table_only_rebuilds_around_a_level_move_complete_exactly() { + bench_support::alloc::assert_installed(); let document = riding(); - let model = parse_session_json(&document).expect("model"); let track = first_track(&document); - let rebuild = split_ride(&track, (10_000, -6.0), session::AutomationOffset::default()); - let edit = vca_move(-6.0, Some(4_000)); - // (label, the move: none, live or inside a rebuild, blocks heard, expected flags) - let cases = [ - ("no move", None, 0, exact), - ("a live move heard 24", Some(false), 24, fallback), - ("a live move heard 26", Some(false), 26, fallback), - ("a live move heard 30", Some(false), 30, fallback), - ("a live move heard 40 (ended)", Some(false), 40, exact), - ("a move inside a rebuild heard 12", Some(true), 12, fallback), - ]; - for (label, inside, heard, expected) in cases { + let offset = session::AutomationOffset::default(); + for moving in [false, true] { + let label = &format!("a move {moving}"); let mut a = Rig::new(&document); for _ in 0..4 { a.step(); } - match inside { - None => {} - Some(false) => send( + if moving { + send( &mut a, - core::slice::from_ref(&edit), + &[vca_move(-6.0, Some(500))], protocol::EditPath::Live, label, - ), - Some(true) => { - let mut edits = vec![edit.clone()]; - edits.extend(add_muted_track(&model, "added")); - send(&mut a, &edits, protocol::EditPath::Rebuild, label); + ); + for _ in 0..2 { + a.step(); } } - for _ in 0..heard { - a.step(); - } - send( - &mut a, - core::slice::from_ref(&rebuild), - protocol::EditPath::Rebuild, - label, - ); - for _ in 0..4 { - a.step(); - } - assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); - let (flags, fallbacks) = outcome(&a); - assert_eq!(flags, expected, "{label}: the rebuild's outcome flags"); - assert_eq!( - fallbacks, - u64::from(expected == fallback), - "{label}: transition-fallback count" - ); - } - } - - /// B17-R13 tranche 5 (verifier MAJOR 1). A live VCA move with a 500-sample ramp, heard 2 - /// blocks, is stretched to `J` (960) by a table-only rebuild R1, which reports the - /// transition fallback; a second table-only rebuild R2 sent 5 blocks after R1's adoption, - /// inside that landing over `J` but past the move's own 500 samples, stretches it again - /// and reports the fallback too. - /// - /// Test value: red if a flagged rebuild does not keep the level-move window open for `J` - /// from its adoption (R2 then reports `OUTCOME_EXACT`, checked once). - #[test] - fn a_second_reposition_inside_a_stretched_landing_reports_a_fallback() { - let fallback = crate::abi::OUTCOME_TRANSITION_FALLBACK; - let document = riding(); - let track = first_track(&document); - let offset = session::AutomationOffset::default(); - let mut a = Rig::new(&document); - for _ in 0..4 { - a.step(); - } - send( - &mut a, - &[vca_move(-6.0, Some(500))], - protocol::EditPath::Live, - "the move", - ); - for _ in 0..2 { - a.step(); - } - for (label, end_db, fallbacks) in [("R1", -6.0, 1), ("R2", -9.0, 2)] { - send( - &mut a, - &[split_ride(&track, (10_000, end_db), offset)], - protocol::EditPath::Rebuild, - label, - ); - for _ in 0..5 { - a.step(); + for (rebuild, end_db) in [("R1", -6.0), ("R2", -9.0)] { + let label = &format!("{label}, {rebuild}"); + send( + &mut a, + &[split_ride(&track, (10_000, end_db), offset)], + protocol::EditPath::Rebuild, + label, + ); + for _ in 0..5 { + let block = a.block; + let (_, used) = a.step_counted(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "{label}: block {block} allocated or freed" + ); + } + assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); + assert_exact(&a, label); } - assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); - assert_eq!( - outcome(&a), - (fallback, fallbacks), - "{label}: the rebuild's outcome flags and the transition-fallback count" - ); } } @@ -9752,6 +9707,70 @@ mod set_control { } } + /// #1711 G4 (A1): an exact hand unmute that frees a lane held over a moving ride + /// discharges the move the hold owed. The first track rides linearly from 0 to -6 dB over + /// `[0, 1,280)`, holds -6 dB to 2,320, then rides on to -12 dB. A hand mute (ramp 256) at + /// block 4 and an exact hand unmute (ramp 0) at block 8 hold the lane over moving grid + /// points (a move is owed, then the unmute frees the lane at its block's entry); a hand + /// mute (256) at block 12 and a hand unmute (200) at block 16 hold it again where the ride + /// is flat, so nothing is remembered in that hold, whose end `E` is 2,248. From block 9 on + /// every sample equals a rig that took only the second hold, and every block is + /// allocation-free. + /// + /// Test value: red if the free write at the unmute's entry does not discharge the owed + /// move (the `owed = false` of `HeldRampObligation::block`): the stale obligation then + /// lands a move over `J` at `E` with nothing remembered (first difference block 17). + #[test] + fn an_exact_unmute_that_frees_a_held_lane_discharges_the_owed_move() { + bench_support::alloc::assert_installed(); + let document = session_with(|model, first, _| { + let lin = session::AutomationShape::Linear; + model.automation.push(entry( + first, + vec![ + segment(lin, (0, 1_280), (0.0, -6.0)), + segment(lin, (1_280, 2_320), (-6.0, -6.0)), + segment(lin, (2_320, SOURCE_FRAMES), (-6.0, -12.0)), + ], + )); + }); + let track = first_track(&document); + let mute = |muted: bool, ramp: u32| { + [set( + builtin(&track, MUTE, Lane::Both), + f32::from(u8::from(muted)), + Some(ramp), + )] + }; + let first_hold = [(4, mute(true, 256)), (8, mute(false, 0))]; + let second_hold = [(12, mute(true, 256)), (16, mute(false, 200))]; + let mut a = Rig::new(&document); + let mut r = Rig::new(&document); + for _ in 0..40 { + let block = a.block; + for (at, edit) in first_hold.iter().chain(&second_hold) { + if *at == block { + send(&mut a, edit, protocol::EditPath::Live, "both holds"); + } + } + for (at, edit) in &second_hold { + if *at == block { + send(&mut r, edit, protocol::EditPath::Live, "the second hold"); + } + } + let (x, used) = a.step_counted(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "block {block} allocated or freed" + ); + let y = r.step(); + if block >= 9 { + assert_eq!(bits(&x), bits(&y), "block {block}"); + } + } + } + /// U3 fold, MINOR 8 (#1648 D2, the reposition's seeding). A live fader move to -6 dB /// (the session's 960 samples) and a rebuild that adds a ride on the same track commit in /// one gap between blocks. The ride holds 0 dB until sample 100 and -6 dB after, so the @@ -11237,13 +11256,13 @@ fn a_following_send_leaks_nothing_of_a_stored_mute() { /// 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. 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. +/// completes `OUTCOME_EXACT` too, count still 1: the flag marks its own candidate only. /// /// 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), -/// 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). +/// or if the flag outlives its own candidate (the fader rebuild after it then reports the +/// fallback). #[test] fn a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback() { let remove = SessionEdit::RemoveAutomation { @@ -11311,10 +11330,9 @@ 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. + // Fold-2 NIT-1: the flag marks its own candidate only. The seven -> six rebuild above is + // flagged, and a second rebuild that automates a fader in the block after its adoption + // (inside `J` = 240 samples of it) completes exact; the count stays 1. let document = follow_document(true, false); let mut rig = Rig::new(&document); for _ in 0..8 { diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index 18a854725..722185aba 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -1480,7 +1480,6 @@ where observation_armed_bytes: 0, observation_slots_high_water: 0, observation_forwarded: Vec::new(), - level_moves: None, }; let (offsets, pending, counts, armed, armed_bytes) = crate::control::observation_slots( &session.providers.effects, diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index f7cd40464..5923812d0 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -646,89 +646,6 @@ pub struct SessionState { /// from the candidate it superseded (#1310, root ruling B15-Q4); empty after a rebuild that /// superseded nothing. pub(crate) observation_forwarded: Vec, - /// The level moves that may still ramp, as one conservative window (root ruling B17-R14, - /// #1648 split; #1711 deletes it): `None` when none may. - pub(crate) level_moves: Option, -} - -/// Every live level move (a fader or offsets record with a ramp) and every level change a rebuild -/// commits, folded into one bound (B17-R14): the newest revision among them and the longest ramp -/// any of them takes. A move is in effect from at most the watermark's `first_sample` once the -/// watermark covers its revision (the watermark is monotone), so every move has ended once the -/// watermark covers `revision` and the render clock is at or past `first_sample + ramp`. The bound -/// only errs toward "may still ramp", and it is one word pair: nothing grows with the moves. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) struct LevelMoveWindow { - revision: u64, - ramp: u32, -} - -impl LevelMoveWindow { - /// Whether some move of the window may still ramp, given the watermark (`None` when a read - /// gave up: then it may) and the render clock read after it. - fn may_ramp(self, watermark: Option, render_clock: u64) -> bool { - watermark.is_none_or(|watermark| { - watermark.revision < self.revision - || render_clock < watermark.first_sample.saturating_add(u64::from(self.ramp)) - }) - } -} - -/// A transaction's level moves: the newest revision and the longest ramp (B17-R14). -fn fold_level_move(window: &mut Option, revision: u64, ramp: u32) { - *window = Some(match *window { - Some(window) => LevelMoveWindow { - revision: window.revision.max(revision), - ramp: window.ramp.max(ramp), - }, - None => LevelMoveWindow { revision, ramp }, - }); -} - -/// The longest ramp a level change a rebuild commits can take at its successor's first block -/// (B17-R14): the session's fader length (the jump length `J` and the default record ramp), or a -/// longer one the transaction gave a row. -fn rebuild_level_ramp(model: &session::SessionModel, edit_ramps: &session::EditRamps) -> u32 { - edit_ramps - .entries() - .into_iter() - .map(|(_, _, samples)| samples) - .fold( - controls::LiveRamps::for_session(model).fader_samples, - u32::max, - ) -} - -/// Whether some strip both models hold has a different effective fader or `S` on a lane in -/// `next` (B17-R14): a level change the rebuild's successor applies at its first block, and -/// which ramps from there. -fn rebuild_moves_a_level( - committed: &session::SessionModel, - next: &session::SessionModel, - monitor: &controls::MonitorOverlay, - next_monitor: &controls::MonitorOverlay, -) -> bool { - let (before, after) = ( - controls::effective(committed, monitor), - controls::effective(next, next_monitor), - ); - let track_count = committed.tracks.len(); - let ids: Vec<&str> = committed.strips().map(|strip| strip.id.as_str()).collect(); - next.strips().zip(&after.strips).any(|(strip, value)| { - host_core::strip_position( - ids.len(), - track_count, - |index| ids[index], - strip.id.as_str(), - ) - .and_then(|index| before.strips.get(index)) - .is_some_and(|old| { - (0..2).any(|lane| { - old.fader_db[lane].to_bits() != value.fader_db[lane].to_bits() - || old.offsets_sum[lane].to_bits() != value.offsets_sum[lane].to_bits() - }) - }) - }) } /// The stable key of one observation tap, borrowed from the bytes that name it (#1677): the strip's @@ -2618,17 +2535,6 @@ where }; #[cfg(not(feature = "test-support"))] let adoption = PlanAdoption::Next; - // B17-R14 (#1648 split; #1711 deletes this): a successor that automates a fader lane may - // Reposition it (a changed table, or a moved input arrival), and a level move still - // ramping there is landed over `J` when less than `J` is left. Control cannot tell which - // lanes reposition or how much is left, so the candidate completes as the transition - // 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; 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`. - // // #1652 U2a fold 2 (MAJOR-A; #1713 deletes this): a successor whose send join carries a // 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 @@ -2636,42 +2542,11 @@ where // 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(); - let next = session - .compiled() - .ok_or(CommandError::Internal)? - .normalized_model(); - let committed = self.controller.session().compiled().normalized_model(); - let automates = next - .automation - .iter() - .any(|entry| controls::fader_row(&entry.target)); - let in_flight = self.level_moves.is_some_and(|window| { - window.may_ramp( - self.shared.watermark.read().ok(), - self.publisher.render_clock(), - ) - }); - let level_fallback = automates && in_flight; - let outcome = if level_fallback || follow_into_six_word { - engine::realtime::CandidateOutcome::TransitionFallback - } else { - engine::realtime::CandidateOutcome::Exact - }; - let moved = - rebuild_moves_a_level(committed, next, &self.monitor, &monitor_change.overlay) - .then(|| rebuild_level_ramp(next, session.edit_ramps())); - // 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), + // over-reports. + let outcome = if matches!(kind, Rebuild::Transaction(_)) && follow_into_six_word { + engine::realtime::CandidateOutcome::TransitionFallback + } else { + engine::realtime::CandidateOutcome::Exact }; let reservation = match self .publisher @@ -2863,11 +2738,6 @@ where // withdrawn candidate, drops below as a commit's displaced model does. Rebuild::Discontinuity(model) => (None, Some(*model)), }; - // B17-R14: the rebuild's own level changes ramp from its successor's first block, and a - // flagged rebuild's landing over `J` does too. - if let Some(ramp) = rebuild_level { - fold_level_move(&mut self.level_moves, revision, ramp); - } // #1559 D8: the overlay is written after the last fallible step, with the commit. let monitor_ramp = writes_monitor.then_some(monitor_change.ramp); let previous_monitor = core::mem::replace(&mut self.monitor, monitor_change.overlay); @@ -3516,27 +3386,6 @@ where } else { protocol::EditPath::Live }; - // B17-R14: the longest ramp of the level moves this commit writes (fader and offsets - // records with a ramp), folded into the window once committed. - let level_ramp = delta - .strips - .iter() - .flat_map(|strip| { - let fader = strip.fader_records().filter_map(|record| match record { - host_core::TrackFaderRecord::FaderDb { - smoothing_samples, .. - } => Some(smoothing_samples), - host_core::TrackFaderRecord::Mute { .. } => None, - }); - let offsets = strip - .offsets - .iter() - .flatten() - .map(|record| record.smoothing_samples); - fader.chain(offsets) - }) - .filter(|&samples| samples != 0) - .max(); drop(delta); let published = core::mem::take(&mut owners.begun); @@ -3552,9 +3401,6 @@ where ); // #1559 D8: the overlay is written after the last fallible step, with the commit. self.monitor = monitor_change.overlay; - if let Some(ramp) = level_ramp { - fold_level_move(&mut self.level_moves, revision, ramp); - } for index in published { owners.effects[index] .commit_owner() diff --git a/crates/control-plane/src/plan.rs b/crates/control-plane/src/plan.rs index 292b89fae..376f488bc 100644 --- a/crates/control-plane/src/plan.rs +++ b/crates/control-plane/src/plan.rs @@ -197,7 +197,7 @@ impl<'a> ObservedReservation<'a> { } /// The outcome the candidate's own revisions complete with (#1314 D1): the transition - /// fallback for a rebuild that may stretch a level move in flight (B17-R14). + /// fallback for a rebuild that carries a following route into a six-word route (#1713). pub(crate) fn set_outcome(&mut self, outcome: engine::realtime::CandidateOutcome) { self.inner .as_mut() diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index 2285075d2..4daf68a25 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -395,7 +395,7 @@ pub const MUTE_CELL_RULE: CellRule = CellRule { /// 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): the mute row gives no -/// lane a fader cell, a hand-move offset or B17-R14's window. +/// lane a fader cell or a hand-move offset. #[must_use] pub fn automation_row_renders(target: &ControlAddress) -> bool { fader_row(target) || mute_row(target) @@ -411,10 +411,9 @@ pub fn mute_row(target: &ControlAddress) -> bool { } /// 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). +/// gives a lane its fader cell ([`fader_automation`]) and its hand-move offset (#1650). The +/// readers of those test this predicate, not [`automation_row_renders`], so an entry on another +/// rendered row reaches neither of them (#1651 T2). #[must_use] pub fn fader_row(target: &ControlAddress) -> bool { matches!(target.owner, Owner::Strip(_)) diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index f0645849d..014208e7f 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -2239,6 +2239,58 @@ fn acted( (outs.swap_remove(0), entries) } +/// #1711 G4 (A1): an exact live unmute that frees a lane held over a moving ride discharges the +/// move the hold owed. Track 0 (prepared unmuted, fader length 20 ms = 960 samples) rides +/// linearly from `0` to `-6` dB over `[0, 1_280)`, holds `-6` dB to 2,320, then rides on to +/// `-12` dB. A live mute (window 256) at 512 and an exact live unmute (window 0) at 1,024 hold +/// the lane over moving grid points (a move is owed, then the unmute frees the lane at that +/// entry); a mute (256) at 1,536 and an unmute (200) at 2,048 hold it again where the ride is +/// flat, so nothing is remembered in that hold, whose end `E` is 2,248. From 1,152 on both lanes +/// equal the rig that takes only the second hold, at quantum 128 on every render form (Simd8 on +/// AVX2, Simd4, the between-render forms and the scalar oracle). +/// +/// Test value: red if the free write at the unmute's entry does not discharge the owed move (the +/// `owed = false` of `HeldRampObligation::block`): the stale obligation then lands a move over +/// `J` at `E` with nothing remembered. +#[cfg(feature = "test-support")] +#[test] +fn an_exact_unmute_that_frees_a_held_lane_discharges_the_owed_move() { + use builtins::BuiltinLaneSelector::Both; + const FRAMES: u64 = 5_120; + let (model, _) = muted_ride( + vec![ + segment(AutomationShape::Linear, 0, 1_280, 0.0, -6.0), + segment(AutomationShape::Linear, 1_280, 2_320, -6.0, -6.0), + segment(AutomationShape::Linear, 2_320, 1 << 20, -6.0, -12.0), + ], + 20.0, + ); + let model = never_muted(&model); + let mute = |at: u64, muted: bool, window: u32| Act { + mute: Some((Both, muted, window)), + ..act(at) + }; + let second = [mute(1_536, true, 256), mute(2_048, false, 200)]; + let both: Vec = [mute(512, true, 256), mute(1_024, false, 0)] + .into_iter() + .chain(second) + .collect(); + let (out, entries) = acted(&model, 128, FRAMES, &both); + assert_eq!( + entries, + [512, 1_024, 1_536, 2_048], + "each act lands at its own entry" + ); + let (reference, _) = acted(&model, 128, FRAMES, &second); + for plane in 0..2 { + assert_eq!( + first_difference(&out[plane][1_152..], &reference[plane][1_152..]), + None, + "plane {plane}: first difference after 1,152" + ); + } +} + /// `model` with track 0 prepared unmuted: the never-muted twin. #[cfg(feature = "test-support")] fn never_muted(model: &SessionModel) -> SessionModel { diff --git a/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index e0001c6ef..5be40e410 100644 --- a/docs/C_ABI_V1_QUALIFICATION.md +++ b/docs/C_ABI_V1_QUALIFICATION.md @@ -150,10 +150,8 @@ before it pushes a record, so the watermark never reads a revision the gate cann inside render's publication (bounded at 64 attempts) is `RESULT_BACKPRESSURE` with `out` untouched, and the host retries. In this slice every revision completes as `exact`; the `superseded` and `transition_fallback` producers are #1310, #1397 and #1358. `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), 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; #1713 removes it; #1652 U2a fold 2). The frozen exported +also reports 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; #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 ea5cce6a8..a151b8016 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -2930,16 +2930,17 @@ fn a_fader_automation_edit_rebuilds_alike_on_both_hosts() { } } -/// B17-R14 (#1648 split, #1711): a table-only rebuild of a riding fader while a hand move with -/// its own 4,000 samples may still ramp completes as the transition fallback on both hosts, and -/// the same rebuild with no move in flight completes exactly. Both hosts take the one decision in -/// the shared control plane: the browser's status words carry the C ABI's watermark, flags -/// included, and both hosts render the same bits. +/// #1711 G1 across hosts (D2): a table-only rebuild of a riding fader while a hand move with its +/// own 4,000 samples still ramps completes exactly on both hosts, as the same rebuild with no move +/// in flight does: the adoption keeps the move's own length, so the B17-R14 transition fallback is +/// deleted. Both hosts take the one decision in the shared control plane: the browser's status +/// words carry the C ABI's watermark, flags included, neither host counts a transition fallback, +/// and both hosts render the same bits. /// -/// Test value: red if either host marks the rebuild differently (an adapter-local rule), or if -/// the shared decision is not reached through the browser's control path. +/// Test value: red if either host reports the rebuild differently (an adapter-local rule), or if +/// a rebuild with a level move in flight completes as the transition fallback. #[test] -fn a_reposition_swap_while_a_level_move_ramps_reports_the_fallback_alike_on_both_hosts() { +fn a_hand_move_in_flight_across_a_table_only_rebuild_completes_exactly_alike_on_both_hosts() { let ride = |end_db: f32| SessionEdit::UpsertAutomation { automation: session::Automation { id: id("t1-fader-ride"), @@ -2969,14 +2970,7 @@ fn a_reposition_swap_while_a_level_move_ramps_reports_the_fallback_alike_on_both Some(4_000), ); for rate in RATES { - for (label, moved, expected) in [ - ("no move", false, capi::OUTCOME_EXACT), - ( - "a hand move in flight", - true, - capi::OUTCOME_TRANSITION_FALLBACK, - ), - ] { + for (label, moved) in [("no move", false), ("a hand move in flight", true)] { let model = model_at(rate); let mut browser = Rig::new(&model); let mut c_abi = CAbi::new(&model); @@ -3004,9 +2998,19 @@ fn a_reposition_swap_while_a_level_move_ramps_reports_the_fallback_alike_on_both "{rate} Hz, {label}: the browser status carries the C ABI's watermark" ); assert_eq!( - watermark.2, expected, + watermark.2, + capi::OUTCOME_EXACT, "{rate} Hz, {label}: the rebuild's outcome flags" ); + 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, {label}: no transition fallback on either host" + ); } } } @@ -3015,7 +3019,8 @@ fn a_reposition_swap_while_a_level_move_ramps_reports_the_fallback_alike_on_both /// heard 3 blocks, then a table-only rebuild to -5 dB (the move's target moves): the adoption /// defers the cell's jump to the move's first free sample on both hosts alike, through the one /// shared driver. Both hosts give one verdict and reply per edit and render the same bits in every -/// block, through the move's end and the deferred jump over the fader length. +/// block, through the move's end and the deferred jump over the fader length, and the rebuild +/// completes exactly on both (`OUTCOME_EXACT`, no transition fallback; #1711 D2). /// /// Test value: red if either host adopts the moved target differently (an adapter-local rule, or /// a browser successor that does not carry the stage's ramp the deferral waits for). @@ -3072,93 +3077,6 @@ fn a_moved_target_defers_its_jump_alike_on_both_hosts() { &mut c_abi, ); same_blocks(&mut browser, &mut c_abi, 7..60, "the moved target"); - assert_eq!( - status_watermark(&browser), - c_abi.watermark(), - "{rate} Hz: the browser status carries the C ABI's watermark" - ); - } -} - -/// #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), @@ -3168,7 +3086,7 @@ fn a_mute_curve_rebuild_while_a_level_move_ramps_completes_exactly_on_both_hosts assert_eq!( watermark.2, capi::OUTCOME_EXACT, - "{rate} Hz: the mute-only rebuild completes exactly" + "{rate} Hz: the rebuild's outcome flags" ); assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); assert_eq!( 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 cf23f3e5c..bdce6d935 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts @@ -779,10 +779,9 @@ export interface MisoStatus { /// @internal readonly appliedSample: bigint; /// The watermark's outcome flags, the C ABI's bits: `EXACT` 1, `TRANSITION_FALLBACK` 2, - /// `SUPERSEDED` 4. `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; #1713 removes it). + /// `SUPERSEDED` 4. `TRANSITION_FALLBACK` also reports 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; #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 cf23f3e5c..bdce6d935 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -779,10 +779,9 @@ export interface MisoStatus { /// @internal readonly appliedSample: bigint; /// The watermark's outcome flags, the C ABI's bits: `EXACT` 1, `TRANSITION_FALLBACK` 2, - /// `SUPERSEDED` 4. `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; #1713 removes it). + /// `SUPERSEDED` 4. `TRANSITION_FALLBACK` also reports 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; #1713 removes it). /// @internal readonly appliedOutcome: bigint; /// The module's memory size now: at least the boot size (`MisoAudioWorkletHost.memoryBytes`), From 1872b54c2d806a4d2c3284b9d5868e0fe8c30ae3 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 13:22:48 +0000 Subject: [PATCH 09/36] Gate the owed discharge alike on both hosts (#1711 G4 parity leg) Co-Authored-By: Claude Opus 5.5 --- ...ves-own-length-across-a-reposition-swap.md | 13 +- hosts/host-web/tests/control_parity.rs | 118 ++++++++++++++++++ 2 files changed, 127 insertions(+), 4 deletions(-) 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 494aada05..7466d98d7 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 @@ -433,12 +433,17 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af step one sample after the swap must jump at its own sample (B17-R13's 8.6e-2 measurement kept as history). The #1713 test's doc and comment no longer name the level-move window ("the flag marks its own candidate only"). -- **Departure: no G4 parity case in `control_parity.rs`.** The coordinator's tranche scoped G4 to - host-core (Simd8, Simd4) and the C ABI; the host-web parity leg of A3 G4 is a phase-2 - placeholder. +- **G4 on both hosts.** `an_exact_unmute_discharges_the_owed_move_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`): the A1 setup on `t1` at the four rates; the + browser and the C ABI give the same verdicts, replies, watermark (`EXACT`, no transition + fallback) and bits in every block, and from block 24 on both equal a pair of rigs that took + only the second hold. Block 24: the first hold's own difference ends by block 21 at every rate + (the session's latency), and the stale obligation's difference spans blocks 21 to 28 at + 44.1 kHz and 25 to 40 at 96 kHz. Red with the line deleted (run once, restored): block 24 at + 44.1 kHz. - Gates run: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` on control-plane, capi, host-core, builtins-compiler, host-web, controls; `cargo check --locked --all-targets` (no features) on the first five; tests: control-plane (`test-support`), capi lib (211 passed, 1 ignored), host-core `stored_fader_automation` (34), `stored_mute_automation` (13), - `successor_swap` (140), host-web `control_parity` (30): all green. `scripts/check-capi-abi.sh`, + `successor_swap` (140), host-web `control_parity` (31 with the G4 parity case): all green. `scripts/check-capi-abi.sh`, `check-sdk-generated.sh`, `check-sdk-types.sh`: ok. Realtime policy: 168 regions (unchanged). diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index a151b8016..c027a58d2 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -3100,6 +3100,124 @@ fn a_moved_target_defers_its_jump_alike_on_both_hosts() { } } +/// #1711 G4 across hosts (A1): an exact hand unmute that frees a lane held over a moving ride +/// discharges the move the hold owed, alike on both hosts. `t1` rides linearly from 0 to -6 dB +/// over `[0, 1,280)`, holds -6 dB to 2,320, then rides on to -12 dB. A hand mute (ramp 256) at +/// block 4 and an exact hand unmute (ramp 0) at block 8 hold the lane over moving grid points; a +/// hand mute (256) at block 12 and a hand unmute (200) at block 16 hold it again where the ride is +/// flat (nothing is remembered there; the hold ends at 2,248). Both hosts give one verdict and +/// reply per edit, the same watermark (`EXACT`, no transition fallback) and the same bits in +/// every block, and from block 24 on every block equals the pair of rigs that took only the +/// second hold, at the four rates. Block 24 is past the first hold's own difference at every +/// rate (measured: equal from block 12, 13, 21 and 22 at 44.1, 48, 88.2 and 96 kHz), and inside +/// the difference a stale obligation makes (measured once with the line deleted: blocks 21 to 28 +/// at 44.1 kHz up to 25 to 40 at 96 kHz). +/// +/// Test value: red if the free write at the unmute's entry does not discharge the owed move (the +/// `owed = false` of `HeldRampObligation::block`, through the browser's control path as through +/// the C ABI's): the stale obligation lands a move over `J` at the second hold's end. +#[test] +fn an_exact_unmute_discharges_the_owed_move_alike_on_both_hosts() { + let linear = |start: u64, end: u64, from: f32, to: f32| session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: session::ParameterUnit::Db, + }; + 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![ + linear(0, 1_280, 0.0, -6.0), + linear(1_280, 2_320, -6.0, -6.0), + linear(2_320, 1_000_000, -6.0, -12.0), + ], + offset: session::AutomationOffset::default(), + }, + }; + let mute = |muted: bool, ramp: u32| { + CAbi::edits( + Edit::Mute { + strip: T1, + lanes: Lanes::Both, + muted, + }, + Some(ramp), + ) + }; + let first_hold = [(4, mute(true, 256)), (8, mute(false, 0))]; + let second_hold = [(12, mute(true, 256)), (16, mute(false, 200))]; + for rate in RATES { + let model = model_at(rate); + let (mut browser, mut c_abi) = (Rig::new(&model), CAbi::new(&model)); + let (mut browser_r, mut c_abi_r) = (Rig::new(&model), CAbi::new(&model)); + let both = |edits: &[SessionEdit], browser: &mut Rig, c_abi: &mut CAbi, label: &str| { + let (b, b_reply) = browser.transaction_reply(edits); + let (c, c_reply) = c_abi.transaction(edits); + assert_eq!(b, c, "{rate} Hz, {label}: one verdict"); + assert_eq!(b_reply, c_reply, "{rate} Hz, {label}: one reply"); + }; + both( + std::slice::from_ref(&ride), + &mut browser, + &mut c_abi, + "the ride", + ); + both( + std::slice::from_ref(&ride), + &mut browser_r, + &mut c_abi_r, + "the ride, reference", + ); + for block in 0..48 { + for (at, edits) in first_hold.iter().chain(&second_hold) { + if *at == block { + both(edits, &mut browser, &mut c_abi, "both holds"); + } + } + for (at, edits) in &second_hold { + if *at == block { + both(edits, &mut browser_r, &mut c_abi_r, "the second hold"); + } + } + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{rate} Hz: block {block}, both holds"); + let (b_r, c_r) = (browser_r.step(block), c_abi_r.step(block)); + assert_eq!(b_r, c_r, "{rate} Hz: block {block}, the second hold"); + if block >= 24 { + assert_eq!( + b, b_r, + "{rate} Hz: block {block} against the second hold alone" + ); + } + } + 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: outcome flags"); + 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 From 202724b74fe2c67d76341d644ab676b98031b5aa Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 13:26:14 +0000 Subject: [PATCH 10/36] Add the shared merged automation walk (K1, #1654/#1655) crates/automation/src/compose.rs walks K curve cursors into one composed output per caller (a route column now, a VCA member channel in #1655). It reuses CellState's decisions, now pub(crate) in events.rs and used by CellEvents itself (no behaviour change): grid events are held while a jump or a deferral runs; at the column ramp's end each moved value lands over its own J; coincident events take the longest jump (R19-4). K = 1 gives CellEvents' event stream exactly (differential over the corpus and a seeded generator); the walk is allocation-free. Realtime policy 171. Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 65 ++ crates/automation/src/compose.rs | 647 ++++++++++++++++++ crates/automation/src/compose/tests.rs | 545 +++++++++++++++ crates/automation/src/corpus.rs | 26 +- crates/automation/src/events.rs | 171 ++++- crates/automation/src/lib.rs | 7 +- crates/automation/tests/allocation.rs | 70 ++ 7 files changed, 1485 insertions(+), 46 deletions(-) create mode 100644 crates/automation/src/compose.rs create mode 100644 crates/automation/src/compose/tests.rs diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 09f771076..c07da5adf 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -479,3 +479,68 @@ Sizes (x86-64, measured): `RouteFollowFlags` 6 -> 12 bytes, `FollowRouteControlR the bind charge) 352 -> 360. Charges read `size_of`, so no pinned number moved. Realtime policy: 170 marked regions in 34 files (168 before: `GraphRouteJumpLengths::for_row` and `route_gain_law` added; host-core's jump-rule region replaced in place). + +## Decision record: K1 (batch 19) + +`crates/automation` only (R19-2 = (b), K1). No render consumer yet: T3 (the route op) and #1655 +(batch 19b) bind it. + +- **Shipped.** New `crates/automation/src/compose.rs`: `Composed` (one output), `ComposeCursor` + (one curve: its cell, `CursorKind::{Value, Gate}`, its own `CellState`, its block jump length, + its heard value), `Heard` (the values a composition reads), `ComposedEvent`, `Composable` + (`same_bits`; `f32`, `bool`, arrays, pairs) and `ComposeError`. The cursor table is a boxed + slice allocated at bind (`Composed::new`, `try_reserve_exact`, `Composed::bytes(K) = K * 104` on + x86-64); no fixed array, no cap on `K`. The walk is one realtime-policy region: no index or + unwrap paths, bounded by `Composed::max_events` (the sum of the cursors' bounds; each step merges + at least one cursor event). +- **How the walk works.** Each cursor's events are `CellEvents`, unchanged, pulled one at a time + over a window of the block (`NodeSpan::window`, new `pub(crate)`) from the offset after its last + event; with the state as its only memory this is the cell's uninterrupted stream (gate 1 holds + it). At each offset the composition (a caller callback over the heard values) gives `T`, and + the output's own `CellState` decides with the cell's rules, now `pub(crate)` in `events.rs` and + called by `CellEvents` itself (`unchanged_with`, `lagging`, `hold_event`, `commit_ramp`, + `clear_for_landing`, `free_after`, `target_toward`): change only on the target's bits, a jump + also when the ramp in flight lags, a held-only offset emits a held event by the held-event rule, + a seek landing always emits. The G5 corpus digests are unchanged (`wasm-gates` + `g5_native_corpus`: 7 passed). +- **R19-4.** One offset's events are one retarget over the longest ramp among them (Set > Jump > + Grid for the kind). +- **R19-3, and the owed landing: K1 owns it, through the cursors' own `CellState` deferral; the + fader's `HeldRampObligation` is not copied and stays the fader's.** A gate cursor's jump that + retargets the output, and a caller column jump (`Composed::retarget(target, start, n)`: a + record's ramp, a gate change of the cell), defer every value cursor's jump to the column ramp's + first free sample (`CellState::defer_jump`, #1711 D1). While it is pending, the value cursor's + grid is held (#1711's rule): the heard value and the output do not move. At the free sample each + value cursor whose curve moved fires the deferred jump over its own jump length to the curve at + the jump's completion (change only, or always with a held event outstanding), and coincident + landings take the longest (R19-4): "the value row's own J, the longer one if both are owed". The + owed bit is the cursor's held flag. A gate jump is never held; a value cursor's own jumps hold + its own grid by the cell's rule (a late grid restarts it, as one cell's does); a seek landing is + not a column jump. A cursor that pulled past the offset of a column jump is returned to the + state its pull started from and replayed up to that offset (it gave no event there), then + deferred and pulled again, so the deferral acts exactly at the offset. +- **Departure: value jumps are not column jumps.** R19-3 lists a gate change, a record ramp and a + Set. A gain or matrix jump (a discontinuity, a landing) does not hold the other value cursors: a + landing that held the others would make two riding curves land in turn, each over its own `J`, + for ever. During a value jump the column is free (R19-3: "a free column retargets at once"). +- **Departure: no caller `hold_until` on the output.** R19-3 makes every caller ramp a column jump + (`retarget`). The one-cell hold semantics is used only by gate 1's corpus case (the test sets the + output's and the cursor's states directly). +- **API for T3 and #1655.** `seed`/`seed_target`, `compose_heard`, `compose_toward(program, start, + n)` (each curve toward a ramp's completion: a record's target, #1649 D3), `retarget`, + `defer_jump(at)`/`clear_deferred_jump()` (value cursors, R19-5 at a Reposition), + `cursor_mut`/`cursors_mut` (`set_jump` per block, `state_mut` for a verbatim carry or a + reposition, `set_heard`), `copy_from` (a preallocated probe twin), `max_events`. + +Gates (`cargo test --locked -p automation`, all green): +`compose::tests::one_cursor_gives_its_cells_events_over_the_corpus` (K = 1 equals `CellEvents` +over the six G5 cases, holds and both seek modes), +`one_cursor_gives_its_cells_events_over_random_tables` (400 seeded draws: all shapes, gaps, both +completions, `no_restart`, fixed and block jumps, seeks, reported ramps, deferrals), +`flat_curves_leave_the_moving_curves_events` (K = 3, two flat, 150 draws, every place and kind), +`a_grid_event_inside_a_gate_jump_is_held_and_lands_at_its_end` (unmute J 480 at 1,000: no +retarget in between, landing at 1,480 over 256 to the curve at 1,735, grid again from 1,728), +`coincident_jumps_take_the_longest` (480/960 both ways: one Jump over 960), +`a_deferral_holds_the_grid_until_it_fires`, `a_caller_retarget_holds_the_value_grid_until_its_end`, +and `tests/allocation.rs::the_merged_walk_allocates_nothing` (10,000 blocks, seeks, retargets, +deferrals: 0 allocations, 0 frees). Realtime policy: 171 marked regions in 35 files (170 before). diff --git a/crates/automation/src/compose.rs b/crates/automation/src/compose.rs new file mode 100644 index 000000000..a4781b3ff --- /dev/null +++ b/crates/automation/src/compose.rs @@ -0,0 +1,647 @@ +//! The merged walk: several curves composed into one output (#1654 K1, root rulings R19-2 to +//! R19-5). +//! +//! An output (a route column, a member fader or mute channel) hears `K` stored curves. Each curve +//! has a cursor: its cell in the plan's [`Program`], its own [`CellState`], its own jump length and +//! the grid period of its cell. The cursors' events are generated by [`CellEvents`], unchanged, and +//! merged in offset order. At every offset that has an event the caller's composition turns the +//! values the output has heard into one target `T`, and the output's own [`CellState`] decides, +//! with the same change-only, lagging and held-event rules as one cell, whether the output +//! retargets. Nothing here re-derives a cell decision: the rules are [`CellState`]'s. +//! +//! # The rules +//! +//! - **Change only.** An output retargets only when the composed target's bits change +//! ([`Composable::same_bits`]), or a held event is outstanding, or (for a jump) the ramp in +//! flight still lags. +//! - **Coincident events (R19-4).** Events of several curves at one offset are one retarget, over +//! the longest ramp among them. A jump wins over a grid event at the same offset, and a seek +//! landing over both. +//! - **Column jumps hold the value grid (R19-3).** A cursor is a [`CursorKind::Gate`] (a mute +//! curve: its jumps are the output's gate changes) or a [`CursorKind::Value`]. When a gate jump +//! retargets the output, and when the caller retargets it ([`Composed::retarget`]: a record's +//! ramp, a gate change of the cell), every value cursor defers its jump to the first free sample +//! of that ramp ([`CellState::defer_jump`], #1711 D1). Until it fires, the value cursor's grid +//! events are held: the heard values do not move and the output does not retarget, so no grid +//! event cuts the column jump short. At the jump's end each value cursor whose curve moved jumps +//! over its own jump length to the curve at the jump's completion (the deferred jump's rule, change +//! only, or always with a held event outstanding), and the coincident landings take the longest +//! one (R19-4). A gate jump is never held. A free output retargets at once over the event's own +//! ramp. +//! - **A cursor's own jumps** follow its cell's rules: the grid inside a curve's own jump is held +//! by its own [`CellState`] unless its ramp completes later (a restart), as one cell's is. +//! +//! With one cursor and the identity composition the output's events are exactly that cell's +//! [`CellEvents`], so a single curve composes as it renders alone. +//! +//! # Realtime +//! +//! [`Composed::new`] allocates the cursor table, a boxed slice sized at bind (no fixed array, no +//! cap on `K`), on the control thread; [`Composed::bytes`] gives its bytes first. Everything else +//! is render-reachable and allocates, locks and blocks on nothing. A block's walk is bounded: each +//! step consumes at least one cursor event, so it takes at most [`Composed::max_events`] steps. + +use alloc::boxed::Box; +use alloc::vec::Vec; + +use crate::events::target_toward; +use crate::{CellEvents, CellState, Completion, Event, EventKind, NodeSpan, Program, SeekMode}; + +/// A composed target whose bits decide change only. +pub trait Composable: Copy { + /// `true` when `self` and `other` have the same bits (`+0.0` and `-0.0` differ). + fn same_bits(&self, other: &Self) -> bool; +} + +impl Composable for f32 { + fn same_bits(&self, other: &Self) -> bool { + self.to_bits() == other.to_bits() + } +} + +impl Composable for bool { + fn same_bits(&self, other: &Self) -> bool { + self == other + } +} + +impl Composable for [T; N] { + fn same_bits(&self, other: &Self) -> bool { + self.iter().zip(other).all(|(one, two)| one.same_bits(two)) + } +} + +impl Composable for (A, B) { + fn same_bits(&self, other: &Self) -> bool { + self.0.same_bits(&other.0) && self.1.same_bits(&other.1) + } +} + +/// What a cursor's curve is to the output. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CursorKind { + /// A value curve (a gain, a matrix entry, an offset): its grid is held while a column jump + /// runs. + Value, + /// A gate curve (a mute): its jumps are column jumps, never held. + Gate, +} + +/// Why [`Composed::new`] refuses a cursor table. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ComposeError { + /// No cursor. + Empty, + /// The cursor at `cursor` names a cell the program does not hold. + Cell { + /// The cursor's index in the table. + cursor: usize, + }, + /// The table could not be allocated. + Allocation, +} + +/// One curve of an output: its cell, kind, event state and heard value. +#[derive(Clone, Copy, Debug)] +pub struct ComposeCursor { + cell: u32, + kind: CursorKind, + state: CellState, + /// The jump length of a [`crate::JumpLength::Block`] cell for this block. + jump: u32, + /// The value the output has heard from this curve: its last event that was not held. + heard: f32, + // The walk's scratch, meaningful only inside [`Composed::walk`]. + /// The state before the pull that produced `next`, and the offset that pull started at. + before: CellState, + resume: u32, + /// The pulled event not yet merged. + next: Option, + /// The value shown to a held composition at the current offset. + shown: f32, + /// Whether the current offset merged an event of this cursor. + consumed: bool, +} + +impl ComposeCursor { + /// The cursor's cell in the plan's program. + #[must_use] + pub const fn cell(&self) -> u32 { + self.cell + } + + /// The cursor's kind. + #[must_use] + pub const fn kind(&self) -> CursorKind { + self.kind + } + + /// The cell's event state. + #[must_use] + pub const fn state(&self) -> &CellState { + &self.state + } + + /// The cell's event state, for a carry (a continued cell takes its predecessor's verbatim) or + /// a reposition. + pub const fn state_mut(&mut self) -> &mut CellState { + &mut self.state + } + + /// The value the output has heard from this curve. + #[must_use] + pub const fn heard(&self) -> f32 { + self.heard + } + + /// Sets the heard value (a carry, a placement). + pub const fn set_heard(&mut self, value: f32) { + self.heard = value; + } + + /// Sets the block's jump length for a [`crate::JumpLength::Block`] cell (the caller resolves + /// the cursor's row against the block's ramp lengths); a fixed-length cell ignores it. + pub const fn set_jump(&mut self, jump: u32) { + self.jump = jump; + } +} + +/// The values a composition reads: one per cursor, in table order. +#[derive(Clone, Copy)] +pub struct Heard<'a> { + cursors: &'a [ComposeCursor], + source: Source<'a>, +} + +#[derive(Clone, Copy)] +enum Source<'a> { + /// The heard values. + Heard, + /// The heard values with the current offset's held events shown. + Shown, + /// Each curve toward the completion of a ramp of `n` samples from `start`. + Toward { + program: &'a Program, + start: i64, + n: u32, + }, +} + +/// One event of a composed output. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ComposedEvent { + /// The sample of the block the event applies at. + pub offset: u32, + /// The composed target. + pub value: T, + /// The ramp length in samples: the longest of the offset's events (`0` for a seek's + /// [`EventKind::Set`]). + pub ramp: u32, + /// [`EventKind::Set`] at an exact seek landing, [`EventKind::Jump`] when a jump or a ramped + /// landing is among the offset's events, else [`EventKind::Grid`]. + pub kind: EventKind, + /// Only held events at this offset: the output does not retarget. + pub held: bool, +} + +/// One composed output: its cursor table and its own event state (#1654 K1). +#[derive(Debug)] +pub struct Composed { + cursors: Box<[ComposeCursor]>, + /// The output's ramp in flight and held flag; its own target bits are unused (`target`). + state: CellState, + target: Option, + completion: Completion, +} + +impl Composed { + /// The bytes [`Self::new`] allocates for `cursors` cursors. + #[must_use] + pub const fn bytes(cursors: usize) -> usize { + cursors.saturating_mul(size_of::()) + } + + /// The output over `cells` (each a cell of `program` and its kind), whose ramps complete under + /// `completion`. Every cursor is seeded with its curve's first `start_value` (the value a + /// prepared stage holds); the output has no target until [`Self::seed`]. Control side: it + /// allocates the table. + /// + /// # Errors + /// + /// [`ComposeError::Empty`] with no cell, [`ComposeError::Cell`] for a cell outside `program`, + /// [`ComposeError::Allocation`] when the table cannot be allocated. + pub fn new( + program: &Program, + cells: &[(u32, CursorKind)], + completion: Completion, + ) -> Result { + if cells.is_empty() { + return Err(ComposeError::Empty); + } + let mut table = Vec::new(); + table + .try_reserve_exact(cells.len()) + .map_err(|_| ComposeError::Allocation)?; + for (cursor, &(cell, kind)) in cells.iter().enumerate() { + let seed = program + .cell(cell as usize) + .and_then(|program| program.segments().first().map(|first| first.start_value)) + .ok_or(ComposeError::Cell { cursor })?; + let mut state = CellState::new(); + state.seed_target(seed); + table.push(ComposeCursor { + cell, + kind, + state, + jump: 0, + heard: seed, + before: state, + resume: 0, + next: None, + shown: seed, + consumed: false, + }); + } + Ok(Self { + cursors: table.into_boxed_slice(), + state: CellState::new(), + target: None, + completion, + }) + } +} + +// REALTIME_POLICY_BEGIN +impl Heard<'_> { + /// How many values there are (the cursors). + #[must_use] + pub const fn len(&self) -> usize { + self.cursors.len() + } + + /// `true` with no value. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.cursors.is_empty() + } + + /// The value of cursor `index`; `0.0` outside the table. + #[must_use] + pub fn value(&self, index: usize) -> f32 { + let Some(cursor) = self.cursors.get(index) else { + return 0.0; + }; + match self.source { + Source::Heard => cursor.heard, + Source::Shown => cursor.shown, + Source::Toward { program, start, n } => program + .cell(cursor.cell as usize) + .map_or(cursor.heard, |cell| { + target_toward(&cell, &cursor.state, start, n) + }), + } + } +} + +impl Composed { + /// The cursors, in table order. + #[must_use] + pub fn cursors(&self) -> &[ComposeCursor] { + &self.cursors + } + + /// The cursor at `index`. + pub fn cursor_mut(&mut self, index: usize) -> Option<&mut ComposeCursor> { + self.cursors.get_mut(index) + } + + /// Every cursor, for a per-block jump length or a carry. + pub fn cursors_mut(&mut self) -> &mut [ComposeCursor] { + &mut self.cursors + } + + /// The output's current target. + #[must_use] + pub const fn target(&self) -> Option { + self.target + } + + /// Sets the output's target to `target`, as a completed retarget: no held event is + /// outstanding, the ramp in flight is kept. + pub fn seed_target(&mut self, target: T) { + self.target = Some(target); + // The output's state holds no target bits of its own (`target` is `T`); the seed clears + // an outstanding held event and keeps the ramp in flight, as a cell's seed does. + self.state.seed_target(0.0); + } + + /// Seeds the output's target with the composition of the heard values (preparation). + pub fn seed(&mut self, compose: impl FnOnce(Heard<'_>) -> T) { + let value = compose(self.heard(Source::Heard)); + self.seed_target(value); + } + + /// The composition of the heard values. + pub fn compose_heard(&self, compose: impl FnOnce(Heard<'_>) -> T) -> T { + compose(self.heard(Source::Heard)) + } + + /// The composition of every curve toward the completion of a ramp of `n` samples from node + /// time `start` (the curve at that completion, never past its next discontinuity): the target + /// of a record that retargets the output over its own ramp (#1649 D3 on a composed output). + /// Reads the cursors' positions, so call it at a block entry, after the block's placement. + pub fn compose_toward( + &self, + program: &Program, + start: i64, + n: u32, + compose: impl FnOnce(Heard<'_>) -> T, + ) -> T { + compose(self.heard(Source::Toward { program, start, n })) + } + + const fn heard<'a>(&'a self, source: Source<'a>) -> Heard<'a> { + Heard { + cursors: &self.cursors, + source, + } + } + + /// A column jump the caller started at node time `start` over `n` samples (a record's ramp, a + /// gate change of the cell): the output takes `target`, and every value cursor defers its jump + /// to the ramp's first free sample (R19-3), so its grid is held until then and it lands over + /// its own jump length if its curve moved. With `n = 0` nothing is in flight and nothing is + /// deferred. Call it between blocks. + pub fn retarget(&mut self, target: T, start: i64, n: u32) { + self.target = Some(target); + self.state.commit_ramp(self.completion, start, n); + if let Some(free) = self.state.free_after(self.completion) { + for cursor in self.cursors.iter_mut() { + if cursor.kind == CursorKind::Value { + cursor.state.defer_jump(free); + } + } + } + } + + /// Defers every value cursor's jump to node time `at` ([`CellState::defer_jump`]): the + /// output's ramp in flight was adopted at a plan swap and its curves moved (#1711 D1, R19-5). + /// Call it between blocks. + pub fn defer_jump(&mut self, at: i64) { + for cursor in self.cursors.iter_mut() { + if cursor.kind == CursorKind::Value { + cursor.state.defer_jump(at); + } + } + } + + /// Drops every value cursor's pending deferral ([`CellState::clear_deferred_jump`]): the ramp + /// it waits for was replaced or ended early. + pub fn clear_deferred_jump(&mut self) { + for cursor in self.cursors.iter_mut() { + if cursor.kind == CursorKind::Value { + cursor.state.clear_deferred_jump(); + } + } + } + + /// Takes `other`'s cursor states, heard values and output state (a twin bound over the same + /// cells: a probe walk's copy). Returns `false`, copying nothing, when the tables differ in + /// length. + pub fn copy_from(&mut self, other: &Self) -> bool { + if self.cursors.len() != other.cursors.len() { + return false; + } + self.cursors.copy_from_slice(&other.cursors); + self.state = other.state; + self.target = other.target; + self.completion = other.completion; + true + } + + /// The most events one block of `frames` samples can give: the sum of the cursors' bounds + /// ([`crate::CellProgram::max_events`]); the walk takes at most this many steps. + #[must_use] + pub fn max_events(&self, program: &Program, frames: u32) -> u32 { + self.cursors + .iter() + .filter_map(|cursor| program.cell(cursor.cell as usize)) + .fold(0_u32, |sum, cell| { + sum.saturating_add(cell.max_events(frames)) + }) + } + + /// The events of the block `span` in increasing offset, at most one per offset, each passed + /// to `emit`; `compose` turns the heard values into the output's target. Every cursor's state + /// advances through the block. A cursor whose cell the program does not hold gives no event. + pub fn walk( + &mut self, + program: &Program, + span: NodeSpan, + mode: SeekMode, + mut compose: impl FnMut(Heard<'_>) -> T, + mut emit: impl FnMut(ComposedEvent), + ) { + let frames = span.frames(); + for cursor in self.cursors.iter_mut() { + cursor.next = None; + cursor.consumed = false; + cursor.shown = cursor.heard; + pull(cursor, program, &span, mode, 0, frames); + } + // Each pass merges at least one pulled event, and a cursor gives a bounded number per + // block, so the loop ends. + while let Some(offset) = self + .cursors + .iter() + .filter_map(|cursor| cursor.next.map(|event| event.offset)) + .min() + { + let Some(time) = span.time_at(offset) else { + break; + }; + let merged = merge(&mut self.cursors, offset); + let landing = span.step_offset() == Some(offset) && (merged.set || merged.jump); + let event = self.decide(landing, offset, time, merged, &mut compose); + // R19-3: a gate's jump that retargets the output is a column jump; a seek landing is + // not (every cursor landed itself). + let column_jump = + merged.gate_jump && !landing && event.is_some_and(|event| !event.held); + let defer = if column_jump { + self.state.free_after(self.completion) + } else { + None + }; + if let Some(event) = event { + emit(event); + } + let next = offset.saturating_add(1); + for cursor in self.cursors.iter_mut() { + cursor.shown = cursor.heard; + let value = cursor.kind == CursorKind::Value; + if let (Some(free), true) = (defer, value) { + if !cursor.consumed { + // The cursor pulled past this offset before the column jump: replay its + // pull up to here (it gave no event there), then defer and pull again. + replay(cursor, program, &span, mode, next); + } + cursor.state.defer_jump(free); + pull(cursor, program, &span, mode, next, frames); + } else if cursor.consumed { + pull(cursor, program, &span, mode, next, frames); + } + cursor.consumed = false; + } + } + } + + /// The output's decision at `offset` (node time `time`) over the events `merged` there; + /// `landing` when they are the block's seek landings. + fn decide( + &mut self, + landing: bool, + offset: u32, + time: i64, + merged: Merged, + compose: &mut impl FnMut(Heard<'_>) -> T, + ) -> Option> { + let event = |value, ramp, kind, held| ComposedEvent { + offset, + value, + ramp, + kind, + held, + }; + if landing { + // A seek landing: every cursor landed here, and a landing always emits. + let value = compose(self.heard(Source::Heard)); + self.state.clear_for_landing(); + let kind = if merged.set { + EventKind::Set + } else { + EventKind::Jump + }; + let ramp = if merged.set { 0 } else { merged.ramp }; + self.commit(value, time, ramp); + return Some(event(value, ramp, kind, false)); + } + if merged.jump || merged.grid { + let value = compose(self.heard(Source::Heard)); + let same = self.target.is_some_and(|target| target.same_bits(&value)); + let lagging = merged.jump && self.state.lagging(self.completion, time, merged.ramp); + if self.state.unchanged_with(same) && !lagging { + return None; + } + self.commit(value, time, merged.ramp); + let kind = if merged.jump { + EventKind::Jump + } else { + EventKind::Grid + }; + return Some(event(value, merged.ramp, kind, false)); + } + if merged.held { + let value = compose(self.heard(Source::Shown)); + let differs = !self.target.is_some_and(|target| target.same_bits(&value)); + if self.state.hold_event(differs) { + return Some(event(value, merged.held_ramp, EventKind::Grid, true)); + } + } + None + } + + fn commit(&mut self, value: T, time: i64, ramp: u32) { + self.target = Some(value); + self.state.commit_ramp(self.completion, time, ramp); + } +} + +/// What one offset's events were: the kinds of the events that were not held, the longest ramp +/// among them (R19-4), whether a gate's jump is among them, and the held events' longest ramp. +#[derive(Clone, Copy, Default)] +struct Merged { + set: bool, + jump: bool, + grid: bool, + gate_jump: bool, + ramp: u32, + held: bool, + held_ramp: u32, +} + +/// Take every cursor's event at `offset`: a held event is shown, any other is heard. +fn merge(cursors: &mut [ComposeCursor], offset: u32) -> Merged { + let mut merged = Merged::default(); + for cursor in cursors.iter_mut() { + let Some(event) = cursor.next.filter(|event| event.offset == offset) else { + continue; + }; + cursor.next = None; + cursor.consumed = true; + cursor.shown = event.value; + if event.held { + merged.held = true; + merged.held_ramp = merged.held_ramp.max(event.ramp); + continue; + } + cursor.heard = event.value; + merged.ramp = merged.ramp.max(event.ramp); + match event.kind { + EventKind::Set => merged.set = true, + EventKind::Jump => { + merged.jump = true; + merged.gate_jump |= cursor.kind == CursorKind::Gate; + } + EventKind::Grid => merged.grid = true, + } + } + merged +} + +/// Pull `cursor`'s next event at or after offset `from` of the block `span` (`frames` long), +/// remembering the state it started from. +fn pull( + cursor: &mut ComposeCursor, + program: &Program, + span: &NodeSpan, + mode: SeekMode, + from: u32, + frames: u32, +) { + cursor.before = cursor.state; + cursor.resume = from; + cursor.next = None; + let (Some(cell), Some(window)) = ( + program.cell(cursor.cell as usize), + span.window(from, frames), + ) else { + return; + }; + cursor.next = CellEvents::new(cell, &mut cursor.state, window, mode, cursor.jump) + .next() + .map(|event| Event { + offset: event.offset.saturating_add(from), + ..event + }); +} + +/// Return `cursor` to the state its last pull started from and run the block's offsets up to +/// `to` again, where that pull found no event, so the state is the one a stream stopped at `to` +/// would hold. +fn replay(cursor: &mut ComposeCursor, program: &Program, span: &NodeSpan, mode: SeekMode, to: u32) { + cursor.state = cursor.before; + cursor.next = None; + let (Some(cell), Some(window)) = ( + program.cell(cursor.cell as usize), + span.window(cursor.resume, to), + ) else { + return; + }; + let mut events = CellEvents::new(cell, &mut cursor.state, window, mode, cursor.jump); + // The pull found its first event after `to`, so the replay gives none; drained to its end. + for _ in events.by_ref() {} +} +// REALTIME_POLICY_END + +#[cfg(test)] +mod tests; diff --git a/crates/automation/src/compose/tests.rs b/crates/automation/src/compose/tests.rs new file mode 100644 index 000000000..15bcfd7fb --- /dev/null +++ b/crates/automation/src/compose/tests.rs @@ -0,0 +1,545 @@ +//! The merged walk's gates (#1654 K1). + +use alloc::vec::Vec; + +use super::{Composed, ComposedEvent, CursorKind, Heard}; +use crate::corpus::{CASE_COUNT, blocks, case_of}; +use crate::{ + CellDescription, CellEvents, CellState, Completion, Event, EventKind, JumpLength, NodeSpan, + Program, ProgramBuilder, SeekMode, Segment, Shape, +}; + +const fn seg(start: u64, end: u64, v0: f32, v1: f32, shape: Shape) -> Segment { + Segment { + start, + end, + start_value: v0, + end_value: v1, + shape, + } +} + +/// A fader-rule cell (grid and ramp 64, `End`, block jump length, restartable). +const fn fader_cell(segments: &[Segment]) -> CellDescription<'_> { + CellDescription { + segments, + grid_ramp: 64, + grid: 64, + jump: JumpLength::Block, + completion: Completion::End, + no_restart: false, + } +} + +/// `(offset, value bits, ramp, kind, held)`: what a differential compares. +type Word = (u32, u32, u32, EventKind, bool); + +const fn word(event: &Event) -> Word { + ( + event.offset, + event.value.to_bits(), + event.ramp, + event.kind, + event.held, + ) +} + +const fn composed_word(event: &ComposedEvent) -> Word { + ( + event.offset, + event.value.to_bits(), + event.ramp, + event.kind, + event.held, + ) +} + +fn identity(heard: Heard<'_>) -> f32 { + heard.value(0) +} + +/// A seeded reference state, as [`Composed::new`] seeds its cursors. +fn seeded(program: &Program, cell: usize) -> CellState { + let mut state = CellState::new(); + if let Some(first) = program + .cell(cell) + .and_then(|cell| cell.segments().first().copied()) + { + state.seed_target(first.start_value); + } + state +} + +/// One block's caller operations before its events, applied alike to the reference cell and the +/// one-cursor output: a ramp the caller reports, a jump deferred. +#[derive(Clone, Copy, Default)] +struct Ops { + hold: Option, + defer: Option, +} + +/// The reference cell's events and the one-cursor output's over the same blocks, per block. +fn one_cursor_streams( + program: &Program, + blocks: &[(NodeSpan, u32, Ops)], + mode: SeekMode, +) -> (Vec>, Vec>) { + let cell = program.cell(0).expect("cell 0"); + let mut reference = seeded(program, 0); + let mut composed = + Composed::::new(program, &[(0, CursorKind::Value)], cell.completion).expect("bind"); + composed.seed(identity); + let (mut expected, mut actual) = (Vec::new(), Vec::new()); + for &(span, jump, ops) in blocks { + if let Some(completion) = ops.hold { + reference.hold_until(completion); + // A ramp the caller reports on one cell is the cell's and the output's. + composed.state.hold_until(completion); + if let Some(cursor) = composed.cursor_mut(0) { + cursor.state_mut().hold_until(completion); + } + } + if let Some(at) = ops.defer { + reference.defer_jump(at); + composed.defer_jump(at); + } + expected.push( + CellEvents::new(cell, &mut reference, span, mode, jump) + .map(|event| word(&event)) + .collect::>(), + ); + if let Some(cursor) = composed.cursor_mut(0) { + cursor.set_jump(jump); + } + let mut events = Vec::new(); + composed.walk(program, span, mode, identity, |event| { + events.push(composed_word(&event)); + }); + actual.push(events); + } + (expected, actual) +} + +/// K1 gate 1, corpus half: one cursor under the identity composition gives exactly its cell's +/// events, block for block, over every case of the G5 corpus (its tables, cell rules, node-time +/// mappings, seeks of both modes and the ramps it reports). +/// +/// Red if the merge, a resumed pull or the output's change-only, lagging or held-event rule +/// departs from one cell's. +#[test] +fn one_cursor_gives_its_cells_events_over_the_corpus() { + for case in 0..CASE_COUNT { + let spec = case_of(case); + let program = ProgramBuilder::build(&[CellDescription { + segments: &spec.table, + grid_ramp: spec.ramp, + grid: spec.ramp.max(64), + jump: spec.jump, + completion: spec.completion, + no_restart: spec.no_restart, + }]) + .expect("corpus cell"); + let blocks: Vec<_> = blocks(case) + .into_iter() + .map(|block| { + ( + block.span, + block.jump, + Ops { + hold: block.hold, + defer: None, + }, + ) + }) + .collect(); + let (expected, actual) = one_cursor_streams(&program, &blocks, spec.mode); + let total: usize = expected.iter().map(Vec::len).sum(); + assert!(total > 10, "case {case} must give events: {total}"); + assert_eq!(actual, expected, "case {case}"); + } +} + +/// A xorshift64* generator: the differential's tables and mappings, reproducible from a seed. +struct Rng(u64); + +impl Rng { + const fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + + const fn below(&mut self, bound: u64) -> u64 { + let bound = if bound == 0 { 1 } else { bound }; + self.next() % bound + } + + const fn chance(&mut self, percent: u64) -> bool { + self.below(100) < percent + } + + fn value(&mut self, positive: bool) -> f32 { + let value = (self.below(4_000) as f32 - 2_000.0) / 50.0; + if positive { value.abs() + 0.5 } else { value } + } +} + +/// A random table: segments of every shape, with gaps, adjacent joins and equal-valued joins. +/// Draws whose discontinuities break the 64-sample spacing are refused by the builder, and the +/// caller draws again. +fn random_table(rng: &mut Rng) -> Vec { + let mut table = Vec::new(); + let mut time = rng.below(500); + for _ in 0..1 + rng.below(12) { + let length = 64 + rng.below(3_000); + let shape = match rng.below(3) { + 0 => Shape::Step, + 1 => Shape::Linear, + _ => Shape::Exponential, + }; + let positive = shape == Shape::Exponential; + let start = rng.value(positive); + let end = if rng.chance(20) { + start + } else { + rng.value(positive) + }; + table.push(seg(time, time + length, start, end, shape)); + time += length + if rng.chance(50) { 0 } else { rng.below(800) }; + } + table +} + +/// A random cell rule over a random table, built, and a seek mode. +fn random_program(rng: &mut Rng) -> (Program, SeekMode) { + loop { + let table = random_table(rng); + let ramp = if rng.chance(70) { 64 } else { 128 }; + let description = CellDescription { + segments: &table, + grid_ramp: ramp, + grid: ramp, + jump: if rng.chance(70) { + JumpLength::Block + } else { + JumpLength::Fixed(64 * (1 + rng.below(8) as u32)) + }, + completion: if rng.chance(70) { + Completion::End + } else { + Completion::After + }, + no_restart: rng.chance(20), + }; + let mode = if rng.chance(70) { + SeekMode::Exact + } else { + SeekMode::Ramp + }; + if let Ok(program) = ProgramBuilder::build(&[description]) { + return (program, mode); + } + } +} + +/// A random node-time mapping: blocks of a random quantum, seeks forward and backward at random +/// offsets, reported ramps and deferred jumps. +fn random_blocks(rng: &mut Rng) -> Vec<(NodeSpan, u32, Ops)> { + let quantum = [1, 37, 64, 100, 128, 256, 300][rng.below(7) as usize]; + let mut node = rng.below(400) as i64 - 200; + let mut out = Vec::new(); + for _ in 0..120 { + let step = if rng.chance(6) { + Some(( + rng.below(u64::from(quantum)) as u32, + rng.below(16_000) as i64, + )) + } else { + None + }; + let jump = [0, 64, 240, 480, 960][rng.below(5) as usize]; + let hold = if rng.chance(8) { + Some(node + rng.below(900) as i64) + } else { + None + }; + let defer = if rng.chance(8) { + Some(node + rng.below(900) as i64) + } else { + None + }; + let Ok(span) = NodeSpan::new(node, step, quantum) else { + continue; + }; + out.push((span, jump, Ops { hold, defer })); + node = match step { + Some((offset, time)) => time + i64::from(quantum - offset), + None => node + i64::from(quantum), + }; + } + out +} + +/// K1 gate 1, generator half: one cursor gives exactly its cell's events over 400 random tables, +/// cell rules and mappings (seeks of both modes, reported ramps, deferred jumps). +/// +/// Red if a resumed pull is not the uninterrupted cell's stream anywhere the generator reaches (a +/// seek inside a window, a deferral behind a run, a `no_restart` cell's re-deferral), or the +/// output's rules differ from the cell's. +#[test] +fn one_cursor_gives_its_cells_events_over_random_tables() { + let mut rng = Rng(0x9E37_79B9_7F4A_7C15); + let mut total = 0; + for draw in 0..400 { + let (program, mode) = random_program(&mut rng); + let blocks = random_blocks(&mut rng); + let (expected, actual) = one_cursor_streams(&program, &blocks, mode); + total += expected.iter().map(Vec::len).sum::(); + assert_eq!(actual, expected, "draw {draw}"); + } + assert!(total > 10_000, "the generator must reach events: {total}"); +} + +/// K1 gate 2: with every curve but one flat, the output under a composition that passes the +/// moving curve through (a product with the flat curves at 1.0) gives exactly that curve's events, +/// whatever the flat curves' kinds and the moving curve's place in the table. +/// +/// Red if a flat curve's cursor changes the merge (an event of its own, a deferral it starts, a +/// landing it lengthens). +#[test] +fn flat_curves_leave_the_moving_curves_events() { + let mut rng = Rng(0xD1B5_4A32_D192_ED03); + let flat = [seg(0, 1 << 40, 1.0, 1.0, Shape::Step)]; + for draw in 0..150 { + let (moving, mode) = random_program(&mut rng); + let rule = moving.cell(0).expect("cell"); + let moving_cell = CellDescription { + segments: rule.segments(), + grid_ramp: rule.ramp, + grid: rule.grid, + jump: rule.jump, + completion: rule.completion, + no_restart: rule.no_restart, + }; + // A flat curve lands a ramped seek over no ramp, so the landing keeps the moving one's. + let flat_cell = CellDescription { + jump: JumpLength::Fixed(0), + ..fader_cell(&flat) + }; + let program = ProgramBuilder::build(&[flat_cell, moving_cell]).expect("program"); + let place = rng.below(3) as usize; + let cells: Vec<_> = (0..3) + .map(|index| match index { + _ if index == place => (1, CursorKind::Value), + 0 => (0, CursorKind::Gate), + _ => (0, CursorKind::Value), + }) + .collect(); + let mut composed = Composed::::new(&program, &cells, rule.completion).expect("bind"); + let product = |heard: Heard<'_>| (0..heard.len()).map(|index| heard.value(index)).product(); + composed.seed(product); + let mut reference = seeded(&program, 1); + let reference_cell = program.cell(1).expect("cell"); + for (span, jump, _) in random_blocks(&mut rng) { + let expected: Vec = + CellEvents::new(reference_cell, &mut reference, span, mode, jump) + .map(|event| word(&event)) + .collect(); + for cursor in composed.cursors_mut() { + cursor.set_jump(jump); + } + let mut actual = Vec::new(); + composed.walk(&program, span, mode, product, |event| { + actual.push(composed_word(&event)); + }); + assert_eq!(actual, expected, "draw {draw}"); + } + } +} + +/// The non-held events of a block of 4,000 samples from node time 0, as `(offset, kind, ramp)`, +/// and the value of the one at `at`, if any. +fn retargets( + program: &Program, + composed: &mut Composed, + compose: impl FnMut(Heard<'_>) -> f32, + at: u32, +) -> (Vec<(u32, EventKind, u32)>, Option) { + let span = NodeSpan::new(0, None, 4_000).expect("span"); + let mut out = Vec::new(); + let mut value = None; + composed.walk(program, span, SeekMode::Exact, compose, |event| { + if !event.held { + out.push((event.offset, event.kind, event.ramp)); + if event.offset == at { + value = Some(event.value); + } + } + }); + (out, value) +} + +/// The output `gain` while the mute curve (1 until 1,000, then 0: an unmute) is open, `0.0` while +/// it is closed, with the block lengths `[mute, gain]`. +fn gate_and(gain: &[Segment], lengths: [u32; 2]) -> (Program, Composed) { + let mute = [seg(0, 1_000, 1.0, 0.0, Shape::Step)]; + let program = ProgramBuilder::build(&[fader_cell(&mute), fader_cell(gain)]).expect("program"); + let mut composed = Composed::new( + &program, + &[(0, CursorKind::Gate), (1, CursorKind::Value)], + Completion::End, + ) + .expect("bind"); + composed.seed(gated); + for (cursor, jump) in composed.cursors_mut().iter_mut().zip(lengths) { + cursor.set_jump(jump); + } + (program, composed) +} + +fn gated(heard: Heard<'_>) -> f32 { + if heard.value(0) == 0.0 { + heard.value(1) + } else { + 0.0 + } +} + +/// K1 gate 3 (R19-3): a gain grid event inside the unmute's jump does not cut it. The unmute at +/// 1,000 jumps over the mute length (480) and nothing retargets the output before 1,480, where +/// the gain lands over its own length (256) to the curve at the landing's last frame; its grid +/// then moves the output again from the first grid point whose ramp ends after the landing's +/// (1,728), and on every grid point after it. +/// +/// Red if a grid event inside another curve's jump retargets the output (cuts the fade), if the +/// held move is not landed at the jump's end, or if it lands over the gate's length. +#[test] +fn a_grid_event_inside_a_gate_jump_is_held_and_lands_at_its_end() { + let ride = [seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let (program, mut composed) = gate_and(&ride, [480, 256]); + let (events, landing) = retargets(&program, &mut composed, gated, 1_480); + let window: Vec<_> = events + .iter() + .copied() + .filter(|&(offset, ..)| offset < 1_750) + .collect(); + assert_eq!( + window, + [ + (1_000, EventKind::Jump, 480), + (1_480, EventKind::Jump, 256), + (1_728, EventKind::Grid, 64), + ] + ); + let ride_cell = program.cell(1).expect("cell"); + assert_eq!( + landing.map(f32::to_bits), + Some(crate::value_at(ride_cell.segments(), 1_480 + 255).to_bits()) + ); + let after: Vec<_> = events + .iter() + .filter(|&&(offset, ..)| offset > 1_728) + .collect(); + assert!(after.len() > 30); + assert!( + after.iter().all(|&&(offset, kind, ramp)| offset % 64 == 0 + && kind == EventKind::Grid + && ramp == 64) + ); +} + +/// K1 gate 4 (R19-4): a gate jump and a value jump at one offset are one retarget over the longer +/// jump, whichever curve owns it. +/// +/// Red if coincident events take one curve's length regardless, or emit two events at one offset. +#[test] +fn coincident_jumps_take_the_longest() { + // The gain steps from -6 to -12 at 1,000, where the unmute is. + let step = [ + seg(0, 1_000, -6.0, -6.0, Shape::Step), + seg(1_000, 9_000, -12.0, -12.0, Shape::Step), + ]; + for lengths in [[480, 960], [960, 480]] { + let (program, mut composed) = gate_and(&step, lengths); + let (events, value) = retargets(&program, &mut composed, gated, 1_000); + assert_eq!(events, [(1_000, EventKind::Jump, 960)], "{lengths:?}"); + assert_eq!(value, Some(-12.0)); + } +} + +/// One riding value cursor bound with the block length 480. +fn riding(program: &Program) -> Composed { + let mut composed = + Composed::::new(program, &[(0, CursorKind::Value)], Completion::End).expect("bind"); + composed.seed(identity); + if let Some(cursor) = composed.cursor_mut(0) { + cursor.set_jump(480); + } + composed +} + +/// K1 gate 5 (#1711 D1 on the output): a jump deferred to 300 holds the riding curve's grid until +/// it fires: every grid event before 300 is held, and at 300 the output jumps over the block's +/// length. Cleared, the grid moves the output from its first point. +/// +/// Red if the output retargets on a grid point before the deferral fires, or the deferral fires +/// nothing, or a cleared one still holds the grid. +#[test] +fn a_deferral_holds_the_grid_until_it_fires() { + let ride = [seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let program = ProgramBuilder::build(&[fader_cell(&ride)]).expect("program"); + let span = NodeSpan::new(0, None, 512).expect("span"); + let mut deferred = riding(&program); + deferred.defer_jump(300); + let mut events = Vec::new(); + deferred.walk(&program, span, SeekMode::Exact, identity, |event| { + events.push((event.offset, event.kind, event.ramp, event.held)); + }); + assert_eq!( + events.get(..6), + Some( + &[ + (0, EventKind::Grid, 64, true), + (64, EventKind::Grid, 64, true), + (128, EventKind::Grid, 64, true), + (192, EventKind::Grid, 64, true), + (256, EventKind::Grid, 64, true), + (300, EventKind::Jump, 480, false), + ][..] + ) + ); + let mut cleared = riding(&program); + cleared.defer_jump(300); + cleared.clear_deferred_jump(); + let mut first = None; + cleared.walk(&program, span, SeekMode::Exact, identity, |event| { + first = first.or(Some((event.offset, event.kind, event.held))); + }); + assert_eq!(first, Some((0, EventKind::Grid, false))); +} + +/// R19-3 on a record: a column jump the caller starts ([`Composed::retarget`] over 200 samples +/// from 0, to the curve at its completion) holds the value grid until its first free sample, +/// where the curve lands over its own length; a zero-length retarget defers nothing. +/// +/// Red if a caller's column jump does not hold the value grid, or holds it past its end. +#[test] +fn a_caller_retarget_holds_the_value_grid_until_its_end() { + let ride = [seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let program = ProgramBuilder::build(&[fader_cell(&ride)]).expect("program"); + for (length, first) in [ + (200, (200, EventKind::Jump, 480)), + (0, (0, EventKind::Grid, 64)), + ] { + let mut composed = riding(&program); + let target = composed.compose_toward(&program, 0, length, identity); + assert_eq!( + target.to_bits(), + crate::value_at(&ride, i64::from(length.saturating_sub(1))).to_bits() + ); + composed.retarget(target, 0, length); + let (events, _) = retargets(&program, &mut composed, identity, 0); + assert_eq!(events.first(), Some(&first), "record length {length}"); + } +} diff --git a/crates/automation/src/corpus.rs b/crates/automation/src/corpus.rs index b3d26e90d..52e44a072 100644 --- a/crates/automation/src/corpus.rs +++ b/crates/automation/src/corpus.rs @@ -46,16 +46,16 @@ const fn seg(start: u64, end: u64, v0: f32, v1: f32, shape: Shape) -> Segment { } /// One case's curve table and cell parameters. -struct Case { - table: Vec, - ramp: u32, - jump: JumpLength, - completion: Completion, - no_restart: bool, - mode: SeekMode, +pub(crate) struct Case { + pub(crate) table: Vec, + pub(crate) ramp: u32, + pub(crate) jump: JumpLength, + pub(crate) completion: Completion, + pub(crate) no_restart: bool, + pub(crate) mode: SeekMode, } -fn case_of(case: usize) -> Case { +pub(crate) fn case_of(case: usize) -> Case { let (table, ramp, jump, completion, no_restart, mode) = match case { // A 60 dB linear fade over one second at 48 kHz, a fader cell. 0 => ( @@ -135,14 +135,14 @@ fn case_of(case: usize) -> Case { } /// One block of a case's fixed mapping. -struct Block { - span: NodeSpan, - jump: u32, +pub(crate) struct Block { + pub(crate) span: NodeSpan, + pub(crate) jump: u32, /// A ramp to report to the state before the block, as a completion node time. - hold: Option, + pub(crate) hold: Option, } -fn blocks(case: usize) -> Vec { +pub(crate) fn blocks(case: usize) -> Vec { let mut out = Vec::new(); let mut push = |start: i64, step: Option<(u32, i64)>, frames: u32, jump: u32, hold| { if let Ok(span) = NodeSpan::new(start, step, frames) { diff --git a/crates/automation/src/events.rs b/crates/automation/src/events.rs index e35b571de..4dcf62938 100644 --- a/crates/automation/src/events.rs +++ b/crates/automation/src/events.rs @@ -81,6 +81,56 @@ impl NodeSpan { frames, }) } + + /// The block length. + pub(crate) const fn frames(&self) -> u32 { + self.frames + } + + /// The seek step's offset, if the block has one. + pub(crate) const fn step_offset(&self) -> Option { + match self.step { + Some((offset, _)) => Some(offset), + None => None, + } + } + + /// The node time at `offset` (the step's time from the step on); `None` past the block or the + /// `i64` range. + pub(crate) fn time_at(&self, offset: u32) -> Option { + if offset >= self.frames { + return None; + } + match self.step { + Some((step, time)) if offset >= step => time.checked_add(i64::from(offset - step)), + _ => self.start.checked_add(i64::from(offset)), + } + } + + /// The offsets `[from, to)` of this block as a span of their own, whose offset 0 is `from`: + /// the same node times, and the seek step when it lies inside the window. `None` for an empty + /// or out-of-block window, or past the `i64` range. A cell's events over the window are the + /// block's events at those offsets, shifted by `from`, for a state advanced to `from`: the + /// event generator keeps nothing across offsets but its [`CellState`]. + pub(crate) fn window(&self, from: u32, to: u32) -> Option { + if from >= to || to > self.frames { + return None; + } + let frames = to - from; + let (start, step) = match self.step { + Some((step, time)) if from > step => (time.checked_add(i64::from(from - step))?, None), + Some((step, time)) if step < to => ( + self.start.checked_add(i64::from(from))?, + Some((step - from, time)), + ), + _ => (self.start.checked_add(i64::from(from))?, None), + }; + Some(Self { + start, + step, + frames, + }) + } } /// The ramp in flight, the current target and the cursor of one cell, kept across blocks. @@ -214,6 +264,85 @@ impl CellState { None } } + + /// `true` when a retarget whose target has the same bits as the current one (`same`, which + /// is `false` with no target) changes nothing the stage holds: no held event is outstanding. + /// The change-only rule of every grid and jump event, shared with the merged walk + /// ([`crate::compose`]). + pub(crate) const fn unchanged_with(&self, same: bool) -> bool { + same && !self.held + } + + /// `true` when the ramp in flight completes after a ramp of `ramp` samples started at `time` + /// would ([`completion_of`] under `completion`): it is still short of its target there, so + /// change only must not drop a jump. + pub(crate) const fn lagging(&self, completion: Completion, time: i64, ramp: u32) -> bool { + self.ramp_done != IDLE && self.ramp_done > completion_of(completion, time, ramp) + } + + /// The held-event rule: a held event whose value `differs` from the current target (or with + /// no target) is outstanding; one that does not is emitted only to clear an outstanding one. + /// Returns whether the held event is emitted. + pub(crate) const fn hold_event(&mut self, differs: bool) -> bool { + if !differs && !self.held { + return false; + } + self.held = differs; + true + } + + /// A retarget of `n` samples from `start` committed: no held event is outstanding and the + /// ramp in flight completes at [`completion_of`] (none for `n = 0`). + pub(crate) const fn commit_ramp(&mut self, completion: Completion, start: i64, n: u32) { + self.held = false; + self.ramp_done = if n == 0 { + IDLE + } else { + completion_of(completion, start, n) + }; + } + + /// A seek landing's reset: a deferred jump belongs to the old position and is dropped, and no + /// held event stays outstanding. + pub(crate) const fn clear_for_landing(&mut self) { + self.deferred = IDLE; + self.held = false; + } + + /// The first node time at which the ramp in flight no longer runs under `completion`, if a + /// ramp is in flight. + pub(crate) const fn free_after(&self, completion: Completion) -> Option { + if self.ramp_done == IDLE { + return None; + } + Some(match completion { + Completion::End => self.ramp_done.saturating_add(1), + Completion::After => self.ramp_done, + }) + } +} + +/// The value a ramp of `n` samples started at `start` aims for on `program`, with `state`'s +/// cursor: the curve at its completion, but never past the next discontinuity, which a jump of its +/// own retargets at its exact time. The one rule of every grid, jump and landing target, and of +/// the merged walk's record target ([`crate::compose`]). +pub(crate) fn target_toward( + program: &CellProgram<'_>, + state: &CellState, + start: i64, + n: u32, +) -> f32 { + let mut at = completion_of(program.completion, start, n); + let ahead = program + .discontinuities + .get(state.cursor as usize..) + .unwrap_or(&[]); + if let Some(&next) = ahead.get(ahead.partition_point(|&time| time <= start)) + && next <= at + { + at = next - 1; + } + value_at(program.segments, at) } fn to_cursor(index: usize) -> u32 { @@ -326,46 +455,27 @@ impl<'a, 's> CellEvents<'a, 's> { /// The first node time at which the lane's ramp in flight no longer runs. fn free_at(&self) -> i64 { - if self.state.ramp_done == IDLE { - return IDLE; - } - match self.program.completion { - Completion::End => self.state.ramp_done.saturating_add(1), - Completion::After => self.state.ramp_done, - } + self.state + .free_after(self.program.completion) + .unwrap_or(IDLE) } /// The value a ramp of `n` samples started at `start` aims for: the curve at its completion, /// but never past the next discontinuity, which a jump of its own retargets at its exact time. fn target_at(&self, start: i64, n: u32) -> f32 { - let mut at = self.completion(start, n); - let ahead = self - .program - .discontinuities - .get(self.state.cursor as usize..) - .unwrap_or(&[]); - if let Some(&next) = ahead.get(ahead.partition_point(|&time| time <= start)) - && next <= at - { - at = next - 1; - } - value_at(self.program.segments, at) + target_toward(&self.program, self.state, start, n) } /// `true` when a retarget to `value` changes nothing the stage holds. fn unchanged(&self, value: f32) -> bool { - self.state.has_target && self.state.target == value.to_bits() && !self.state.held + self.state + .unchanged_with(self.state.has_target && self.state.target == value.to_bits()) } fn commit(&mut self, value: f32, start: i64, n: u32) { self.state.target = value.to_bits(); self.state.has_target = true; - self.state.held = false; - self.state.ramp_done = if n == 0 { - IDLE - } else { - self.completion(start, n) - }; + self.state.commit_ramp(self.program.completion, start, n); } /// The landing of the seek step at `offset`, node time `time`. @@ -373,8 +483,7 @@ impl<'a, 's> CellEvents<'a, 's> { let cursor = self.program.discontinuities.partition_point(|&d| d < time); let on_time = self.program.discontinuities.get(cursor) == Some(&time); self.state.cursor = to_cursor(cursor + usize::from(on_time)); - self.state.deferred = IDLE; - self.state.held = false; + self.state.clear_for_landing(); match self.mode { SeekMode::Exact => { let value = value_at(self.program.segments, time); @@ -409,8 +518,7 @@ impl<'a, 's> CellEvents<'a, 's> { // the discontinuity: its target can equal the jump's (a grid ramp aimed at `d - 1` where // `value(d - 1) == value(d)` but the left limit differs), and change only must not drop // the jump then. - let lagging = - self.state.ramp_done != IDLE && self.state.ramp_done > self.completion(time, ramp); + let lagging = self.state.lagging(self.program.completion, time, ramp); if self.unchanged(value) && !lagging { return None; } @@ -437,10 +545,9 @@ impl<'a, 's> CellEvents<'a, 's> { !self.program.no_restart && self.state.deferred != IDLE && time < self.state.deferred; if (in_flight && (self.program.no_restart || done < self.state.ramp_done)) || deferring { let differs = !self.state.has_target || self.state.target != value.to_bits(); - if !differs && !self.state.held { + if !self.state.hold_event(differs) { return None; } - self.state.held = differs; return Some(Event { offset, value, diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 6a6e9178e..dec517bfc 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2,7 +2,8 @@ //! //! A stored curve is a table of [`Segment`]s. This crate does two things with one: it evaluates //! the curve at a node time ([`value_at`]), and it turns a cell's table into the retarget events a -//! stage applies, block by block ([`CellEvents`]). It reads no session, descriptor or plan, and no +//! stage applies, block by block ([`CellEvents`]). [`Composed`] merges several cells' events into +//! one composed output ([`compose`]). It reads no session, descriptor or plan, and no //! stage uses it yet. //! //! # The law @@ -40,12 +41,16 @@ extern crate alloc; +pub mod compose; mod evaluate; mod events; mod program; pub mod corpus; +pub use compose::{ + Composable, ComposeCursor, ComposeError, Composed, ComposedEvent, CursorKind, Heard, +}; pub use evaluate::value_at; pub use events::{ CellEvents, CellState, Event, EventKind, NodeSpan, SeekMode, SpanError, completion_of, diff --git a/crates/automation/tests/allocation.rs b/crates/automation/tests/allocation.rs index 06e79fc37..34b242564 100644 --- a/crates/automation/tests/allocation.rs +++ b/crates/automation/tests/allocation.rs @@ -50,3 +50,73 @@ fn ten_thousand_blocks_allocate_nothing() { assert!(events > 1_000, "the loop must produce events: {events}"); assert_eq!((delta.allocations, delta.deallocations), (0, 0)); } + +/// #1654 K1: the merged walk of a gate and two riding curves over 10,000 blocks, seeks, column +/// jumps, a caller retarget and deferrals all, allocates and frees nothing. +/// +/// Red if a block's walk allocates (a per-block buffer of the cursors' events or values instead of +/// the table bound at bind). +#[test] +fn the_merged_walk_allocates_nothing() { + use automation::{Completion, Composed, CursorKind, Heard, JumpLength, ProgramBuilder}; + assert_installed(); + let mute: Vec<_> = (0..400_u64) + .map(|k| seg(k * 3_000, k * 3_000 + 1_500, 1.0, 0.0, Shape::Step)) + .collect(); + let ride: Vec<_> = (0..300_u64) + .map(|k| { + seg( + k * 700, + k * 700 + 700, + (k % 5) as f32, + ((k * 3) % 7) as f32, + Shape::Linear, + ) + }) + .collect(); + let cell = + |segments| common::description(segments, 64, JumpLength::Block, Completion::End, false); + let program = ProgramBuilder::build(&[cell(&mute), cell(&ride), cell(&ride)]).unwrap(); + let mut composed = Composed::<(f32, bool)>::new( + &program, + &[ + (0, CursorKind::Gate), + (1, CursorKind::Value), + (2, CursorKind::Value), + ], + Completion::End, + ) + .unwrap(); + let compose = |heard: Heard<'_>| (heard.value(1) * heard.value(2), heard.value(0) != 0.0); + composed.seed(compose); + for (cursor, jump) in composed.cursors_mut().iter_mut().zip([480, 256, 960]) { + cursor.set_jump(jump); + } + // Warm: first touches of thread statics. + composed.walk( + &program, + NodeSpan::new(0, None, 128).unwrap(), + SeekMode::Exact, + compose, + |_| {}, + ); + + let mark = current_thread_counters(); + let mut events = 0_usize; + for index in 0..10_000_i64 { + let start = 128 + index * 128; + if index % 131 == 0 { + let target = composed.compose_toward(&program, start, 300, compose); + composed.retarget(target, start, 300); + } + if index % 173 == 0 { + composed.defer_jump(start + 200); + } + let step = (index % 97 == 0).then_some((31, (index * 211) % 200_000)); + let span = NodeSpan::new(start, step, 128).unwrap(); + composed.walk(&program, span, SeekMode::Exact, compose, |_| events += 1); + } + let delta = current_thread_delta_since(mark); + assert!(events > 1_000, "the loop must produce events: {events}"); + assert_eq!((delta.allocations, delta.deallocations), (0, 0)); +} From 015287c628440aa97842484df672ab7cc523e12b Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 13:42:40 +0000 Subject: [PATCH 11/36] Drop a pending deferral at a discontinuity jump (#1711 U1 fold 1, part 1) A step of the new table inside a live move's remaining ramp fired its own J jump but left the deferral pending, so the grid stayed held and the lane froze until the move's end while the watermark said EXACT. A discontinuity jump on a restartable cell now clears the deferral. Host-core gates at Simd8 and Simd4 (red before: sample 2,944, 2.9e-2) and a gate for a hand mute inside a pending deferral. Co-Authored-By: Claude Opus 5.5 --- ...ves-own-length-across-a-reposition-swap.md | 42 +++++ crates/automation/src/events.rs | 11 +- .../tests/stored_fader_automation.rs | 174 +++++++++++++++++- 3 files changed, 214 insertions(+), 13 deletions(-) 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 663e3b079..142660f89 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 @@ -390,3 +390,45 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af host-core `stored_fader_automation` (33), `stored_mute_automation` (13), `successor_swap` (140), `session_seek`, `browser_successor`, capi lib (211), host-web `control_parity` (31): all green. Realtime policy: 168 regions (unchanged). + +## Decision record: U1 fold 1 (batch 19) + +- **Base.** The fold worktree is at `202724b74` (K1), which does not contain F3/F4 (`13abb0cc0`, + `1872b54c2`, on `codex/controls-batch-19f` at `14027678f`); the two merge cleanly (merge-tree + `00c6abf3d`, and this fold's diff applies to it). MAJOR-1's fix, its host-core gate and MINOR-2 + are folded here. The C ABI and both-host MAJOR-1 legs, MINOR-1 and the NIT edit files that F3/F4 + rewrote and are left for the merged base. +- **MAJOR-1 fixed (A2 clear list).** `crates/automation/src/events.rs`, `next_in_run`: a + discontinuity jump on a restartable cell drops a pending deferral (the jump compares the curve + with the target and replaces the ramp the deferral waits for; the stale deferral's grid hold + froze the lane on the step's target until the deferral's time). A `no_restart` cell is + unchanged. The A2 clear list is now: `reposition`, a seek landing, `clear_deferred_jump()` (the + free path of `offsets_jump`, the R17-10 release, the `release_hold_by` sites) and a + discontinuity jump of a restartable cell. `defer_jump`'s doc says so. +- **Effect on K1.** A value cursor's own discontinuity jump inside a column-jump deferral now drops + that cursor's deferral (its grid moves again after its own jump, held by its own ramp by the + cell rule) instead of holding its grid to the column ramp's end. The other cursors keep their + deferrals. K = 1 still equals `CellEvents` (one rule); the K1 tests and the allocation test are + green unchanged. +- **MAJOR-1 gate (host-core, Simd8 and Simd4).** + `a_table_step_inside_a_deferred_moves_ramp_drops_the_deferral` + (`crates/host-core/tests/stored_fader_automation.rs`): VCA and hand moves, heard 1 and 4, the + rebuild `[-5 dB step 0..2,000; linear -8 to -14 dB 2,000..8,000; -14 dB]` against a fresh plan + with -3 dB before 2,000: every sample bit-equal, allocation-free, adopted, carried. Red without + the fix: first difference sample 2,944, max 2.9e-2 (all four cases). +- **MINOR-2 gate (host-core, Simd8 and Simd4).** + `a_hand_mute_inside_a_pending_deferral_lands_the_lane_on_the_new_table_at_the_unmute`: G2 + heard 12 (swap 2,048, F 4,512), hand mute at 2,560, unmute at 3,072 or 5,120, ramps 200 and 0. + `level_move_run_muted` adds live mutes to the carry harness. Inside: the rebuild's table is a + linear -5 to -8 dB to 6,000 (a flat table hides a held grid), and from the mute's end on every + sample is bit-equal to a fresh plan with that table. After F (G2's flat table): bit-equal to the + deferred-jump reference everywhere. Mutation (once each): the R17-10 release without its clear + is red (exact unmute); with all three builtins-compiler clears removed the ramped unmute stays + green, because its owed landing at `E` is a seek landing that clears the deferral. The + `release_lane_hold` sites are not reached by this setup. +- Gates run (own target dir: the shared one held another worktree's `graph` build): `cargo fmt + --all`; clippy `-D warnings --all-targets --all-features` and no-feature `cargo check + --all-targets` on automation and host-core; tests: automation (43, compose included), + builtins-compiler (all features), host-core `stored_fader_automation` (35), + `stored_mute_automation` (13), `successor_swap` (140), capi lib (211), host-web + `control_parity` (31): green. Realtime policy: 171 regions (unchanged). diff --git a/crates/automation/src/events.rs b/crates/automation/src/events.rs index 4dcf62938..7594a3baf 100644 --- a/crates/automation/src/events.rs +++ b/crates/automation/src/events.rs @@ -217,8 +217,9 @@ impl CellState { /// own length. Until the deferral fires, every grid event of a restartable cell is held (the /// stage remembers it and does not retarget), so no grid ramp cuts the running ramp short; a /// `no_restart` cell holds them inside the ramp in flight as it always does. Cleared by - /// [`Self::clear_deferred_jump`], [`Self::reposition`] and a seek landing. Control-free and - /// allocation-free. + /// [`Self::clear_deferred_jump`], [`Self::reposition`], a seek landing and, on a restartable + /// cell, a discontinuity jump (it compares the curve and replaces the ramp the deferral waits + /// for). Control-free and allocation-free. pub const fn defer_jump(&mut self, at: i64) { self.deferred = at; } @@ -631,7 +632,11 @@ impl<'a, 's> CellEvents<'a, 's> { if is_boundary { self.state.cursor += 1; } - if is_deferred { + // A discontinuity jump on a restartable cell drops a pending deferral (#1711 U1 fold + // 1): the jump compares the curve with the current target here and replaces the ramp + // the deferral waits for, so the deferral and its grid hold are stale (they would + // freeze the lane on the jump's target until the deferral's time). + if is_deferred || (is_boundary && !self.program.no_restart) { self.state.deferred = IDLE; } self.position = offset + 1; diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index f0645849d..41848640d 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -2938,6 +2938,21 @@ fn level_move_run( moves: &[(usize, (f64, u32))], backend: graph_compiler::Backend, blocks: usize, +) -> LevelMoveRun { + level_move_run_muted(a, b, moves, &[], backend, blocks) +} + +/// [`level_move_run`] with live hand mutes: at each `(block, (muted, window))` of `mutes`, that +/// block's entry also writes the live mute record `muted` (ramp `window`) on both lanes of track +/// 0, after the block's live moves, to the plan that renders it. +#[cfg(feature = "test-support")] +fn level_move_run_muted( + a: &SessionModel, + b: Option<(&SessionModel, &SessionModel, usize)>, + moves: &[(usize, (f64, u32))], + mutes: &[LiveMute], + backend: graph_compiler::Backend, + blocks: usize, ) -> LevelMoveRun { use builtins_compiler::TestOnlyWriteGate; use engine::realtime::{PlanExchangeConfig, SwapOutcome, plan_exchange}; @@ -3033,6 +3048,22 @@ fn level_move_run( } }); } + for &(_, (muted, smoothing_samples)) in mutes.iter().filter(|(at, _)| *at == block) { + let (handles, gate) = match successor_gate.as_mut() { + Some((handles, gate)) => (handles, gate), + None => (&mut handles, &mut gate), + }; + gate.write_published(|stamp| { + handles.strip_controls[0].fader.write( + stamp, + builtins_compiler::TrackFaderRecord::Mute { + lanes: builtins::BuiltinLaneSelector::Both, + muted, + smoothing_samples, + }, + ); + }); + } let at = block * quantum; successor_sources .as_mut() @@ -3182,32 +3213,70 @@ fn moved_target_tables() -> [Vec; 3] { #[cfg(feature = "test-support")] fn moved_target_case( backend: graph_compiler::Backend, - (what, hand, vca): MovedTargetMove, + case: MovedTargetMove, heard: usize, later: &[(usize, (f64, u32))], from: u64, ) -> Vec { - const AFTER: usize = 44; let [flat, table, fresh] = moved_target_tables(); + let reference = if later.is_empty() { + fresh + } else { + table.clone() + }; + rebuilt_case( + backend, + case, + heard, + [flat, table, reference], + (later, &[]), + from, + ) +} + +/// A live move of [`level_move_run`]: `(block, (S, window))`. +#[cfg(feature = "test-support")] +type LiveMove = (usize, (f64, u32)); + +/// A live hand mute of [`level_move_run_muted`]: `(block, (muted, window))`. +#[cfg(feature = "test-support")] +type LiveMute = (usize, (bool, u32)); + +/// [`moved_target_case`] over `[base, rebuild, reference]` tables with `later` live moves and +/// live hand `mutes` (both given to the rebuilt run and to the fresh reference plan). +#[cfg(feature = "test-support")] +fn rebuilt_case( + backend: graph_compiler::Backend, + (what, hand, vca): MovedTargetMove, + heard: usize, + [flat, table, fresh]: [Vec; 3], + (later, mutes): (&[LiveMove], &[LiveMute]), + from: u64, +) -> Vec { + const AFTER: usize = 44; let swap = MOVE_BLOCK + heard; let blocks = (swap + AFTER).max(later.iter().map(|(at, _)| at + 20).max().unwrap_or(0)); let a = move_model(flat.clone(), 0.0, vca.map(|vca| vca.0)); let committed = move_model(flat, hand, vca.map(|vca| vca.1)); let next = move_model(table.clone(), hand, vca.map(|vca| vca.1)); - let reference = move_model( - if later.is_empty() { fresh } else { table }, - 0.0, - vca.map(|vca| vca.0), - ); + let reference = move_model(fresh, 0.0, vca.map(|vca| vca.0)); let moves: Vec<_> = core::iter::once(MOVE) .chain(later.iter().copied()) .collect(); + let blocks = blocks.max(mutes.iter().map(|(at, _)| at + 20).max().unwrap_or(0)); let label = format!( - "{backend:?}, {what}, heard {heard}, {} later moves", + "{backend:?}, {what}, heard {heard}, {} later moves, mutes {mutes:?}", later.len() ); - let run = level_move_run(&a, Some((&next, &committed, swap)), &moves, backend, blocks); - let expected = level_move_run(&reference, None, &moves, backend, blocks); + let run = level_move_run_muted( + &a, + Some((&next, &committed, swap)), + &moves, + mutes, + backend, + blocks, + ); + let expected = level_move_run_muted(&reference, None, &moves, mutes, backend, blocks); assert_eq!(run.adopted, Some(swap), "{label}: adopted at the swap"); assert!(run.restarted.is_empty(), "{label}: the strip carries"); assert_eq!(run.allocations, (0, 0, 0), "{label}: allocation-free"); @@ -3306,6 +3375,91 @@ fn a_moved_target_lets_a_live_move_longer_than_the_jump_end_when_it_was_due() { } } +/// #1711 U1 fold 1 (MAJOR-1) on host-core, at both bank widths. [`MOVE`] heard 1 and 4 blocks +/// on the ride flat at -3 dB, then a table-only rebuild to -5 dB up to 2,000, a linear -8 to +/// -14 dB over 2,000 to 8,000, then -14 dB: the move's target moves, so the cell defers its jump +/// to the move's first free sample (4,512); the table's step at 2,000 lies inside the move's +/// remaining ramp, and its jump replaces the ramp the deferral waits for. Every sample is +/// bit-equal to a fresh plan whose table is -3 dB before 2,000 (the same table after it), given +/// the same move. +/// +/// Test value: red if a discontinuity jump on a restartable cell leaves a pending deferral in +/// place (its grid hold then freezes the lane on the step's target until 4,512; at the fold's +/// base the first difference was sample 2,944, max 2.9e-2). +#[cfg(feature = "test-support")] +#[test] +fn a_table_step_inside_a_deferred_moves_ramp_drops_the_deferral() { + let stepped = |first: f32| { + let mut segments = steps_of(&[(0, 2_000, first)]); + segments.push(segment(AutomationShape::Linear, 2_000, 8_000, -8.0, -14.0)); + segments.extend(steps_of(&[(8_000, RIDE_FRAMES, -14.0)])); + segments + }; + let [flat, ..] = moved_target_tables(); + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + for case in MOVED_TARGET_MOVES { + for heard in [1, 4] { + rebuilt_case( + backend, + case, + heard, + [flat.clone(), stepped(-5.0), stepped(-3.0)], + (&[], &[]), + 0, + ); + } + } + } +} + +/// #1711 U1 fold 1 (MINOR-2) on host-core, at both bank widths. The G2 setup heard 12 blocks +/// (swap at 2,048, the deferred jump due at 4,512), with a live hand mute at 2,560 and an unmute +/// at 3,072 (inside the deferral) or at 5,120 (after it), each ramp 200 samples or exact. The +/// mute holds the lane and its release clears the deferral, so the lane lands on the new table at +/// the unmute: from the mute's end on, every sample is bit-equal to a fresh plan with the +/// rebuild's table given the same move and mutes. An unmute after 4,512 leaves the deferred jump +/// as it was: every sample is bit-equal to the fresh plan with that jump at 4,512. +/// +/// Test value: red if the release of a held lane leaves a pending deferral in place (its grid +/// hold then freezes the lane after the unmute until 4,512). +#[cfg(feature = "test-support")] +#[test] +fn a_hand_mute_inside_a_pending_deferral_lands_the_lane_on_the_new_table_at_the_unmute() { + const MUTE: usize = 20; + let [flat, _, fresh] = moved_target_tables(); + let mut table = vec![segment(AutomationShape::Linear, 0, 6_000, -5.0, -8.0)]; + table.extend(steps_of(&[(6_000, RIDE_FRAMES, -3.0)])); + let [_, deferred_table, _] = moved_target_tables(); + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + for case in MOVED_TARGET_MOVES { + for window in [200, 0] { + let muted_from = MUTE as u64 * 128 + u64::from(window); + for (unmute, rebuild, reference, from) in [ + (24, &table, &table, muted_from), + (40, &deferred_table, &fresh, 0), + ] { + rebuilt_case( + backend, + case, + 12, + [flat.clone(), rebuild.clone(), reference.clone()], + (&[], &[(MUTE, (true, window)), (unmute, (false, window))]), + from, + ); + } + } + } + } +} + #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] mod canonical_preparation { use super::*; From d8544a5a73b8c9852ceb4c9c6d851c2411ccceb3 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 13:55:38 +0000 Subject: [PATCH 12/36] Prepare send row automation and lower its edits (#1654 T4) Route cells join the one program after the mute cells (route rows 1-6, host.automation.route on refusal); a route with any automated row binds the seven-word lane with flags bits 6-11 and its hand gain offset in word 0. A send automation edit is a carried rebuild; a hand move on an automated send gain writes the offset (live, SendGain); an automated matrix row commits model_only; other rows of such a route write the seven-word cell. The commit's pre-write check compares the whole automated set. Render reads these in T3. Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 61 +++ crates/builtins-compiler/src/lib.rs | 272 +++++++++++++- crates/capi/src/runtime/live_tests.rs | 186 +++++++++ crates/control-plane/src/control.rs | 11 +- .../tests/send_automation_realtime.rs | 355 ++++++++++++++++++ crates/graph/src/lib.rs | 124 +++++- crates/host-core/src/lib.rs | 2 +- crates/host-core/src/live_delta.rs | 19 +- crates/host-core/src/prepare.rs | 228 +++++++---- crates/host-core/tests/live_delta.rs | 297 +++++++++++++++ hosts/host-web/tests/control_parity.rs | 105 ++++++ 11 files changed, 1555 insertions(+), 105 deletions(-) create mode 100644 crates/control-plane/tests/send_automation_realtime.rs diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 09f771076..16c4ea382 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -479,3 +479,64 @@ Sizes (x86-64, measured): `RouteFollowFlags` 6 -> 12 bytes, `FollowRouteControlR the bind charge) 352 -> 360. Charges read `size_of`, so no pinned number moved. Realtime policy: 170 marked regions in 34 files (168 before: `GraphRouteJumpLengths::for_row` and `route_gain_law` added; host-core's jump-rule region replaced in place). + +## Decision record: T4 (batch 19) + +Preparation, records and the classifier; no render code (`crates/graph/src/runtime.rs` and +`crates/automation` untouched). What shipped: + +- **Program.** `prepare_fader_automation` appends the route cells after every mute cell, in + `model.routes` order (routes into a submix only) and row order: rows 1 and 3-6 with + `FADER_CELL_RULE`, row 2 with `MUTE_CELL_RULE`. A session without route automation builds the + same program. New `PreparedBuiltinsSession::route_cells(route_id) -> Option<(Arc, + [Option; 6])>`. A refusal at a route cell is `host.automation.route` (each table now + carries its own code). +- **Predicate.** `following_route_lanes` returns `Option` (new graph + type: flags bits 4-5 `lanes`, bits 6-11 `rows`): `Some` when the route goes into a submix and + its source mute or any route row 1-6 is automated. `follows_automated_mute` follows it, so the + #1713 flag and the `LocalDiff` follower scan widen with no code of their own. +- **Attach and seed.** The attach reads the source's mute cells and the route's cells (one + program, checked against the set), builds the flags with the new `follow_route_flags` (bits + 6-11 from the set; both "#1654 T2" literals gone) and seeds the row 1 hand offset through the + new `GraphFollowRouteSource::with_gain_offset` (`gain_offset()` reads it; `+0.0` by default). +- **Records.** `follow_route_record(route, terms, automated, gain_offset, length)` writes the + offset in word 0 when bit 6 is set. `follow_route_record_between` takes the offsets + `[before, after]` (`route_gain_offset`, the view's row 1 entry, so the in-place path reads the + edited entry, R17-1) and compares only the words render hears: an automated matrix word is + masked, so a hand edit of an automated matrix row gives no record (`model_only`, D4). The ramp + of an offset change is `SendGain` (the edit's own length when given). +- **Classifier.** A send automation edit is step 3 (`LiveRebuild::Automation`); a hand gain move + on an automated send is one seven-word record over `SendGain`; an un-automated row of an + automated send (gain, matrix, mute) writes the seven-word cell (D6), never the folded form. +- **Join.** `RouteJoin::continued_rows` (`controls::continued_route_rows`) reaches the move as the + new `GraphRouteMove::continued_rows: [bool; 6]`, for T3's carry. +- **Commit.** `GraphFollowRouteControlProducer::automated()` returns the whole bound set; + `RouteFollowFlags::automated_set()`; new `host_core::follow_route_producer_for`, which the + pre-write check in `control.rs` now calls (whole set before any write, D9). + +Departures: the pre-write refusal is gated at the helper (`follow_route_producer_for`) and the +producer's write count, not through a commit: a valid session cannot produce a disagreeing set. +`docs/SESSION_SCHEMA_V1.md` is not touched here (T5 or the docs tranche). + +Gates: `builtins-compiler` `the_route_cells_follow_the_mute_cells_in_the_one_program`; `graph` +`follow_route_tests::the_automated_set_is_bits_4_to_11_and_only_an_automated_gain_takes_a_seed_offset`; +host-core `tests/live_delta.rs` `a_send_automation_edit_is_a_rebuild`, +`a_sends_hand_edits_lower_its_offset_and_its_unautomated_rows_only` (offset word, flags, lengths, +`model_only` matrix, D6, fast path equal), `a_route_row_send_binds_its_set_and_the_commit_refuses_another`, +and the join's `continued_rows` pass-through in `prepare.rs`'s route-carry unit test; +control-plane `tests/send_automation_realtime.rs` +`a_send_with_stored_row_automation_renders_allocation_free` (live, model_only, live, live; zero +allocations); capi `a_hand_move_on_an_automated_send_gain_commits_alike_in_place_and_full` and +`removing_a_sends_only_route_entry_reports_a_fallback` (#1713 flag on a route-row-only send); +host-web `a_hand_move_on_an_automated_send_gain_is_an_offset_alike_on_both_hosts` (R17-1, both +hosts). Realtime policy 170 regions in 34 files (unchanged). + +Sizes (x86-64, measured): `GraphFollowRouteSource` 112 (unchanged, the offset fills padding), +`GraphRouteMove` 12 -> 16 (control side only; the render step `RouteCarryStep` is unchanged), +`GraphFollowRouteControlProducer` 88, `RouteAutomatedSet` 8, `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 360 +(unchanged). + +For T3/T5: the route op must read `source.gain_offset()` as the seeded `gain` word when bit 6 is +set (the bound transform's `gain` is the compiler's linear static gain) and +`GraphRouteMove::continued_rows` for the carry. A route-row-only send now binds the seven-word +lane with no source cells, and renders with the old (static) route op until T3 lands. diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index ed3e6af7e..f55aaf362 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -2497,6 +2497,10 @@ struct FaderAutomationPlan { program_bytes: u64, /// Each strip that has an automated fader or mute lane, by track ID. strips: BTreeMap, PreparedStripAutomation>, + /// Each route into a submix that automates a row 1-6 (#1654 D1), by route ID: the program's + /// cell index of each row, in [`controls::route_automation`]'s order, `None` for a row no + /// entry drives. + routes: BTreeMap, [Option; 6]>, } impl FaderAutomationPlan { @@ -2583,9 +2587,12 @@ pub fn fader_automation_sizes() -> FaderAutomationSizes { /// 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. +/// before #1651, cell for cell. Then one cell per automated row 1-6 of each route into a submix +/// (#1654 D1), in `model.routes` order and row order, with the fader row's rule for `gain_db` and +/// the matrix and the mute row's rule for `mute`; they follow every mute cell, so a session with +/// no route automation builds the program it built before #1654. `None` when no row that renders +/// is automated, so a session without stored automation prepares exactly as before; a session +/// with mute (or route) automation only builds those 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 @@ -2621,11 +2628,13 @@ fn prepare_fader_automation( 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 and its row's rule; transient. + // One segment table per cell, in program order, with its entry, its row's refusal code and its + // row's rule; transient. let mut tables: Vec<( &session::Automation, Vec, controls::CellRule, + &'static str, )> = Vec::new(); let mut strips: BTreeMap, PreparedStripAutomation> = BTreeMap::new(); for (strip, values) in model.strips().zip(effective.strips.iter()) { @@ -2662,7 +2671,12 @@ fn prepare_fader_automation( offsets_ramp: ramp, offsets: Arc::new(OffsetsLane::new()), }); - tables.push((entry, segments, controls::FADER_CELL_RULE)); + tables.push(( + entry, + segments, + controls::FADER_CELL_RULE, + "host.automation.fader", + )); } strips.insert( Box::::from(strip.id.as_str()), @@ -2694,7 +2708,12 @@ fn prepare_fader_automation( value, terms: values.mute[lane], }); - tables.push((entry, segments, controls::MUTE_CELL_RULE)); + tables.push(( + entry, + segments, + controls::MUTE_CELL_RULE, + "host.automation.mute", + )); } strips .entry(Box::::from(strip.id.as_str())) @@ -2704,9 +2723,47 @@ fn prepare_fader_automation( }) .mute = cells; } + // #1654 D1: the route cells, after every mute cell, in `model.routes` order and row order: + // rows 1 (`gain_db`) and 3-6 (the matrix) with the fader's rule (grid 64, `End`), row 2 + // (`mute`) with the mute rule (step). Only a route into a submix renders a row (an output + // route's entry is refused at `.target`, D8), so a session without route automation builds + // the program it built before, cell for cell. + let mut routes = BTreeMap::new(); + for route in &model.routes { + if !matches!( + route.destination, + session::RouteDestination::SubmixInput { .. } + ) { + continue; + } + let entries = controls::route_automation(model, &route.id); + if entries.iter().all(Option::is_none) { + continue; + } + let mut cells = [None; 6]; + for (row, 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. + if segments.is_empty() { + return Err(refuse("host.automation.route", entry)); + } + cells[row] = Some( + u32::try_from(tables.len()) + .map_err(|_| diag("host.automation.route", "$.automation"))?, + ); + let rule = if row == 1 { + controls::MUTE_CELL_RULE + } else { + controls::FADER_CELL_RULE + }; + tables.push((entry, segments, rule, "host.automation.route")); + } + routes.insert(Box::::from(route.id.as_str()), cells); + } let descriptions: Vec> = tables .iter() - .map(|(_, segments, rule)| 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 @@ -2724,20 +2781,14 @@ fn prepare_fader_automation( }; cell.and_then(|cell| tables.get(cell)).map_or_else( || diag("host.automation.fader", "$.automation"), - |(entry, _, _)| { - let code = if controls::mute_row(&entry.target) { - "host.automation.mute" - } else { - "host.automation.fader" - }; - refuse(code, entry) - }, + |&(entry, _, _, code)| refuse(code, entry), ) })?; Ok(Some(FaderAutomationPlan { program: Arc::new(program), program_bytes, strips, + routes, })) } @@ -5815,6 +5866,21 @@ impl PreparedBuiltinsSession { .then(|| (Arc::clone(&plan.program), cells)) } + /// Route `route_id`'s stored automation (#1654 D1), for its seven-word lane: the plan's one + /// automation program (the `Arc` [`Self::strip_mute_cells`] hands out) and the program's cell + /// index of each route row 1-6 (`gain_db`, `mute`, `matrix_ll`, `matrix_lr`, `matrix_rl`, + /// `matrix_rr`), `None` for a row no entry drives. `None` when the route automates no row (or + /// the session has no stored automation that renders). Control side; it clones one `Arc`. + #[must_use] + pub fn route_cells( + &self, + route_id: &str, + ) -> Option<(Arc, [Option; 6])> { + let plan = self.fader_automation.as_ref()?; + let cells = *plan.routes.get(route_id)?; + Some((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 @@ -16214,6 +16280,182 @@ mod tests { assert!(program.cell(2).is_none(), "mute cells only"); } + /// #1654 D1 (T4): the route cells follow every mute cell in the plan's one program, in + /// `model.routes` order and row order, with the fader row's rule for `gain_db` and the matrix + /// and the mute row's rule for the route's own `mute`; [`PreparedBuiltinsSession::route_cells`] + /// names each row's cell in that same program. A session without route automation builds the + /// program it built before; one with route automation only builds route cells only. + /// + /// Test value: red if a route cell is placed before a mute cell (the strip cells move), if a + /// row takes another row's rule (a gain on a step table or a mute on the grid), if the export + /// names another program, row or cell, or if a route-only session builds no program. + #[test] + fn the_route_cells_follow_the_mute_cells_in_the_one_program() { + let plain = n_track_session(2); + 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 sid = |text: &str| session::StableId::parse(text).expect("id"); + let segment = |shape, start_value, end_value, unit| session::AutomationSegment { + shape, + start_sample: 0, + end_sample: 4_096, + start_value, + end_value, + unit, + }; + let entry = |name: &str, owner, section, control, segment| session::Automation { + id: sid(name), + target: session::ControlAddress { + owner, + section, + control, + lane: session::ParameterChannel::Both, + }, + segments: vec![segment], + offset: session::AutomationOffset::default(), + }; + let route_entry = |name: &str, control, segment| { + entry( + name, + session::Owner::Route(sid("t00-bus")), + session::Section::Own, + control, + segment, + ) + }; + let gain = route_entry( + "send-gain", + 1, + segment( + session::AutomationShape::Linear, + -6.0, + -2.0, + session::ParameterUnit::Db, + ), + ); + let send_mute = route_entry( + "send-mute", + 2, + segment( + session::AutomationShape::Step, + 0.0, + 1.0, + session::ParameterUnit::Linear, + ), + ); + let lr = route_entry( + "send-lr", + 4, + segment( + session::AutomationShape::Linear, + 0.0, + 0.5, + session::ParameterUnit::Linear, + ), + ); + let strip_mute = entry( + "mute-t00", + session::Owner::Strip(sid(&track_name(0))), + session::Section::Builtins, + 6, + segment( + session::AutomationShape::Step, + 1.0, + 0.0, + session::ParameterUnit::Linear, + ), + ); + let prepare = |automation: Vec| { + let mut model = plain.normalized_model().clone(); + let mut send = model.routes[0].clone(); + send.id = sid("t00-bus"); + send.destination = session::RouteDestination::SubmixInput { + submix_id: sid("bus"), + }; + let mut out = model.routes[0].clone(); + out.id = sid("bus-main"); + out.source = session::RouteSource::Submix { + submix_id: sid("bus"), + tap: session::SendTap::PostPan, + }; + model.routes.push(send); + model.routes.push(out); + let console = model.console.clone(); + model + .submixes + .push(session::Submix::unity(sid("bus"), &console)); + model.automation = automation; + let compiled = compile_session(&model, caps_all).expect("the session compiles"); + prepare_session_builtins(&compiled, &[], caps()).expect("prepared") + }; + let mute_segments = controls::stored_segments(&strip_mute); + let describe = |entry: &session::Automation, rule: controls::CellRule| { + automation::ProgramBuilder::bytes(&[rule.describe(&controls::stored_segments(entry))]) + }; + + let strip_only = prepare(vec![strip_mute.clone()]); + assert!(strip_only.route_cells("t00-bus").is_none()); + let strip_bytes = strip_only + .fader_automation + .as_ref() + .expect("cells") + .program_bytes; + + let both = prepare(vec![ + lr.clone(), + strip_mute.clone(), + gain.clone(), + send_mute.clone(), + ]); + let plan = both.fader_automation.as_ref().expect("cells"); + assert_eq!( + plan.program_bytes, + automation::ProgramBuilder::bytes(&[ + controls::MUTE_CELL_RULE.describe(&mute_segments), + controls::MUTE_CELL_RULE.describe(&mute_segments), + controls::FADER_CELL_RULE.describe(&controls::stored_segments(&gain)), + controls::MUTE_CELL_RULE.describe(&controls::stored_segments(&send_mute)), + controls::FADER_CELL_RULE.describe(&controls::stored_segments(&lr)), + ]), + "the strip's mute cells, then the route's rows in row order with their rules" + ); + let (program, cells) = both.route_cells("t00-bus").expect("the send's cells"); + assert!( + Arc::ptr_eq(&program, &plan.program), + "the plan's one program" + ); + assert_eq!(cells, [Some(2), Some(3), None, Some(4), None, None]); + assert_eq!( + both.strip_mute_cells(&track_name(0)) + .map(|(_, cells)| cells), + Some([Some(0), Some(1)]), + "the strip's cells keep their indices" + ); + assert!(both.route_cells("bus-main").is_none(), "an output route"); + assert_ne!(strip_bytes, plan.program_bytes); + + let route_only = prepare(vec![gain.clone()]); + let (program, cells) = route_only + .route_cells("t00-bus") + .expect("a route-only session builds its route cells"); + assert_eq!(cells, [Some(0), None, None, None, None, None]); + assert!(program.cell(1).is_none(), "route cells only"); + assert_eq!( + route_only + .fader_automation + .as_ref() + .expect("cells") + .program_bytes, + describe(&gain, controls::FADER_CELL_RULE) + ); + } + struct HarnessSink; impl GraphRuntimeProcessor for HarnessSink { diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 21b7220dc..78a958e18 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -11523,3 +11523,189 @@ fn an_edit_of_a_following_route_is_live() { ); } } + +/// #1654 T4: [`follow_document`]`(false, false)` (no source mute curve) with the send `eq0-b` +/// riding its `gain_db` linearly from 0 to -12 dB over the source: a route-row-only send, which +/// the plan gives the seven-word cell. +fn send_ride_document() -> String { + with_model(&follow_document(false, false), |model| { + model.automation.push(session::Automation { + id: StableId::parse("send-gain").expect("automation ID"), + target: session::ControlAddress { + owner: session::Owner::Route(StableId::parse("eq0-b").expect("route ID")), + section: session::Section::Own, + control: 1, + lane: session::ParameterChannel::Both, + }, + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: SOURCE_FRAMES, + start_value: 0.0, + end_value: -12.0, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + }); + }) +} + +/// The send `eq0-b`'s `gain_db` row (route row 1), set to `db`. +fn send_gain_edit(db: f32) -> SessionEdit { + use protocol::control_core::{Lane, Owner, Section, row_address}; + 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"), + db, + None, + ) +} + +/// #1654 R17-6 and root's ruling R17-1 on the C ABI. On a send whose `gain_db` stored entry +/// renders, `SET_CONTROL gain_db = +3` is `live`, one revision on the same plan, one write of the +/// send's seven-word cell; the committed document stores `+3` as the entry's offset on both +/// fields and keeps the static gain. The in-place leg commits in place; the same transaction by +/// the full path gives the same reply, document, cell writes and bits, and no render block of +/// either leg allocates or frees. +/// +/// Test value: red if the in-place path answers otherwise than the full path for the send gain +/// (an owners model with no route entry reads the row as not automated and writes the static +/// gain, or commits `model_only`), or if the move is not one seven-word write. +#[test] +fn a_hand_move_on_an_automated_send_gain_commits_alike_in_place_and_full() { + bench_support::alloc::assert_installed(); + struct Leg { + reply: Vec, + committed: String, + writes: Vec, + blocks: Vec>, + } + let before = send_ride_document(); + let leg = |label: &str| { + let mut rig = Rig::new(&before); + for _ in 0..4 { + rig.step(); + } + let (revision, _, epoch, pending) = rig.summary(); + let bytes = rig.transaction(&[send_gain_edit(3.0)]); + let (result, reply) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK, "{label}"); + assert_eq!(edit_path_of(&reply), protocol::EditPath::Live, "{label}"); + assert_eq!( + (rig.summary().0, rig.summary().2, rig.summary().3), + (revision + 1, epoch, pending), + "{label}: one revision, the same plan, no candidate" + ); + let writes = rig.current_follow_route_cells(); + let committed = rig.snapshot(); + let blocks = (0..12) + .map(|_| { + let (pcm, used) = rig.step_counted(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "{label}: a render block allocated or freed" + ); + bits(&pcm) + }) + .collect(); + Leg { + reply, + committed, + writes, + blocks, + } + }; + control_plane::test_take_in_place_counts(); + let in_place = leg("in place"); + assert_eq!( + control_plane::test_take_in_place_counts(), + (1, 0), + "the hand move commits in place" + ); + assert_eq!(in_place.writes, vec![1], "one seven-word cell write"); + let model = parse_session_json(&in_place.committed).expect("model"); + let entry = model + .automation + .iter() + .find(|entry| entry.id.as_str() == "send-gain") + .expect("the ride"); + assert_eq!( + ( + entry.offset.left_db.to_bits(), + entry.offset.right_db.to_bits() + ), + (3.0_f32.to_bits(), 3.0_f32.to_bits()), + "the entry stores the move on both fields" + ); + let send = model + .routes + .iter() + .find(|route| route.id.as_str() == "eq0-b") + .expect("the send"); + assert_eq!( + send.gain_db.to_bits(), + 0.0_f32.to_bits(), + "the static gain stays" + ); + + control_plane::test_force_full_preparation(true); + let full = leg("full path"); + control_plane::test_force_full_preparation(false); + assert_eq!(control_plane::test_take_in_place_counts().0, 0); + assert_eq!(full.reply, in_place.reply, "one reply on both paths"); + assert_eq!(full.committed, in_place.committed, "one document"); + assert_eq!(full.writes, in_place.writes, "one cell write"); + assert_eq!(full.blocks, in_place.blocks, "the same bits on both paths"); +} + +/// #1654 and the #1713 flag. A rebuild that removes the only route entry of a route-row-only send +/// (seven words -> six) completes as the transition fallback (`OUTCOME_TRANSITION_FALLBACK`, +/// count 1), with no code of its own: the join reads the plan's widened `following`. +/// +/// Test value: red if a route-row-only send is not a following route in the plan's live routes +/// (its seven -> six carry then completes exact while its ramp may be re-timed). +#[test] +fn removing_a_sends_only_route_entry_reports_a_fallback() { + let mut rig = Rig::new(&send_ride_document()); + for _ in 0..8 { + rig.step(); + } + assert_eq!( + rig.current_follow_route_cells(), + vec![0], + "one seven-word send" + ); + let bytes = rig.transaction(&[SessionEdit::RemoveAutomation { + automation_id: StableId::parse("send-gain").expect("automation ID"), + }]); + 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 successor is adopted"); + assert_eq!( + rig.current_follow_route_cells(), + Vec::::new(), + "the send is six-word again" + ); + 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_TRANSITION_FALLBACK, 1), + "the rebuild's revision, outcome flags and transition-fallback count" + ); +} diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 5923812d0..26cba3d71 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -3131,9 +3131,10 @@ where // 2a. Each route's producer, by route ID (#1557 D4): a binary search over the canonical // 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). + // producer of its kind, or a seven-word record whose automated set (flags bits 4-11: the + // source lanes and the route rows, #1654 D2) differs from the plan's (render takes it 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(); @@ -3149,9 +3150,7 @@ where 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, - ) + host_core::follow_route_producer_for(follow_lanes, route.route_id, &record) } }; let Some(index) = index else { diff --git a/crates/control-plane/tests/send_automation_realtime.rs b/crates/control-plane/tests/send_automation_realtime.rs new file mode 100644 index 000000000..7e2e3659e --- /dev/null +++ b/crates/control-plane/tests/send_automation_realtime.rs @@ -0,0 +1,355 @@ +//! #1654 gate 6 (T4): a send whose own rows have stored automation renders allocation-free +//! through the shared commit, across its hand edits. +//! +//! The 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` into bus `b` (no source +//! mute curve), and the send rides its `gain_db` (row 1, linear from 0 to -12 dB) and its +//! `matrix_lr` (row 4). A hand move of the automated gain (`SET_CONTROL` row 1, the hand offset, +//! `live`), a hand edit of the automated `matrix_lr` (automation wins: `model_only`), a live edit +//! of the un-automated `matrix_rr` (D6, `live`) and a hand send mute (`live`) commit in the +//! middle of the ride. Every render call after the warm-up allocates and frees nothing, measured +//! with the audited allocator's thread-scoped counters. + +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"; + +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 `b-main` +/// takes the bus to the output, so the output's right plane is the send. The send rides its +/// `gain_db` from 0 to -12 dB and its `matrix_lr` from 0 to 0.5 over 8,192 samples. +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 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), + ), + route( + "eq0-b", + RouteSource::Track { + track_id: id("eq0"), + tap: SendTap::PreFader, + }, + RouteDestination::SubmixInput { submix_id: id("b") }, + matrix(0.0, 1.0), + ), + 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 ride = |name: &str, control: u32, to: f32, unit| Automation { + id: id(name), + target: ControlAddress { + owner: Owner::Route(id("eq0-b")), + section: Section::Own, + control, + lane: ParameterChannel::Both, + }, + segments: vec![AutomationSegment { + shape: AutomationShape::Linear, + start_sample: 0, + end_sample: 8_192, + start_value: 0.0, + end_value: to, + unit, + }], + offset: AutomationOffset::default(), + }; + model.automation = vec![ + ride("send-gain", 1, -12.0, ParameterUnit::Db), + ride("send-lr", 4, 0.5, ParameterUnit::Linear), + ]; + ( + 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 route row `control` of the send at `value` as one transaction, which must answer + /// `path`. + fn commit(&mut self, control: u32, value: f32, path: EditPath) { + let address = ControlAddress { + owner: Owner::Route(id("eq0-b")), + section: Section::Own, + control, + lane: ParameterChannel::Both, + }; + 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 control commits"); + let mut fields = [0_u16; 64]; + let found = 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 control is refused: {payload:?}") + } + DecodedTypedResponseFrame::Success { .. } => panic!("another success payload"), + }; + assert_eq!(found, path, "row {control}: the commit's path"); + self.revision += 1; + while self + .session + .dequeue_event(EventLane::Reliable, 65_536) + .expect("an event") + .is_some() + {} + } +} + +/// Gate 6. Every render block after the warm-up allocates and frees nothing, across a hand move +/// of the automated send gain at block 6 (`live`: the hand offset), a hand edit of the automated +/// `matrix_lr` at block 8 (`model_only`: automation wins), a live edit of the un-automated +/// `matrix_rr` at block 10 (D6) and a hand send mute at block 14 (`live`). Not vacuous: the send +/// (the output's right plane) carries audio before the mute and is silent once its ramp has ended. +/// +/// Test value: red if the seven-word cell of a route-row-only send, its record writes or the +/// commit's widened pre-write check allocate or free on render, if a hand move of the automated +/// gain or of an un-automated row is not live, if a hand edit of the automated matrix row is +/// acked live, or if the hand mute does not close the send. +#[test] +fn a_send_with_stored_row_automation_renders_allocation_free() { + bench_support::alloc::assert_installed(); + let mut rig = Rig::new(); + for _ in 0..2 { + rig.block(); + } + let mut open_before = false; + let mut silent_after = false; + while rig.block < 32 { + match rig.block { + 6 => rig.commit(1, 3.0, EditPath::Live), + 8 => rig.commit(4, 0.75, EditPath::ModelOnly), + 10 => rig.commit(6, 0.5, EditPath::Live), + 14 => rig.commit(2, 1.0, EditPath::Live), + _ => {} + } + 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 == 12 { + open_before = right.iter().any(|sample| *sample != 0.0); + } + if block == 30 { + silent_after = right.iter().all(|sample| *sample == 0.0); + } + } + assert!(open_before, "the send carries audio before the hand mute"); + assert!(silent_after, "the hand mute closes the send"); +} diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index e9023aed9..b518e6187 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1246,6 +1246,29 @@ impl RouteFollowFlags { } } // REALTIME_POLICY_END + + /// The plan-fixed bits 4 to 11: the automated source lanes and route rows (#1654 D2). A + /// record written to a following route's cell carries the set its plan bound, or the commit + /// refuses it before any write (#1653 D5). + #[must_use] + pub const fn automated_set(self) -> RouteAutomatedSet { + RouteAutomatedSet { + lanes: self.automated, + rows: self.route_rows(), + } + } +} + +/// What a plan automates of one following route (#1654 D2): flags bits 4 and 5 (the source lanes +/// whose mute has a stored curve) and bits 6 to 11 (the route rows 1 to 6 with a stored curve, in +/// [`RouteFollowFlags::route_rows`]'s order). Fixed by the plan; render takes it from its own +/// cursors, never from a record. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RouteAutomatedSet { + /// Per source lane `[L, R]`: whether the plan automates the lane's mute. + pub lanes: [bool; 2], + /// Per route row 1 to 6: whether the plan automates it. + pub rows: [bool; GRAPH_ROUTE_ROWS], } /// One live change of a following route (#1652 D1): its open transform, its flags and the ramp in @@ -1478,6 +1501,9 @@ pub struct GraphFollowRouteSource { )] pub(crate) gain_law: GraphRouteGainLaw, pub(crate) flags: RouteFollowFlags, + /// The seeded hand offset in dB of an automated `gain_db` (#1654, batch 10 amendment): the + /// cell's `gain` word before any record. `+0.0` when the flags do not automate the gain. + pub(crate) gain_offset: f32, } impl GraphFollowRouteSource { @@ -1518,10 +1544,30 @@ impl GraphFollowRouteSource { jumps, gain_law, flags, + gain_offset: 0.0, }, ) } + /// This source with the seeded hand offset `offset_db` of the route's automated `gain_db` + /// (#1654 R17-6): the value the cell's `gain` word holds before the first record. A source + /// from [`Self::new`] seeds `+0.0`, the entry's default offset. `None` when the flags do not + /// automate the gain or `offset_db` is not finite. + #[must_use] + pub fn with_gain_offset(mut self, offset_db: f32) -> Option { + if !self.flags.automated_gain || !offset_db.is_finite() { + return None; + } + self.gain_offset = offset_db; + Some(self) + } + + /// The seeded hand offset in dB of an automated `gain_db` ([`Self::with_gain_offset`]). + #[must_use] + pub const fn gain_offset(&self) -> f32 { + self.gain_offset + } + /// `a(tap)`, the arrival the route's events are timed at. #[must_use] pub const fn arrival(&self) -> u64 { @@ -1564,17 +1610,17 @@ 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], + /// The lanes and route rows the plan automates (the bound source's flags bits 4 to 11, #1652 + /// D1, #1654 D2): render takes them from its own cursors, so a record whose set differs is + /// refused by the commit before any write (#1653 D5). + automated: RouteAutomatedSet, } impl GraphFollowRouteControlProducer { - /// The source lanes whose mute the plan automates, as bound (#1653 D5): the bits every record - /// written to this cell must carry. + /// The source lanes and route rows the plan automates, as bound (#1653 D5, #1654 D2): the set + /// every record written to this cell must carry ([`RouteFollowFlags::automated_set`]). #[must_use] - pub const fn automated(&self) -> [bool; 2] { + pub const fn automated(&self) -> RouteAutomatedSet { self.automated } @@ -1630,7 +1676,7 @@ pub(crate) fn follow_route_control_pair( cell: engine::realtime::LatestCell::new(), dirty: engine::realtime::DirtyWord::new(), }); - let automated = source.flags().automated; + let automated = source.flags().automated_set(); ( GraphFollowRouteControlBinding { node: GraphNodeId::Route { @@ -5095,6 +5141,12 @@ pub struct GraphRouteMove { /// 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, + /// Per route row 1 to 6 ([`RouteFollowFlags::route_rows`]'s order), whether both plans' + /// models automate the row with the same segment table (#1654 D3, + /// `controls::continued_route_rows`): such a row's cell carries its cell state and heard value + /// verbatim, by `(route ID, row)`; another row is repositioned. All `false` for a route + /// neither plan gives a route row cell. + pub continued_rows: [bool; GRAPH_ROUTE_ROWS], } /// The strip delay lines of a bound graph plan, in line order (#1284 D1); `None` for any other @@ -10770,6 +10822,62 @@ mod follow_route_tests { } } + /// #1654 T4: the automated set a commit compares before any write is exactly flags bits 4-11 + /// (each bit alone moves it, no other bit does), and a source seeds a hand offset only for an + /// automated gain and only a finite one. + /// + /// Test value: red if `automated_set` drops a lane or row bit (the commit's pre-write check + /// then admits a record whose set render does not hold) or reads a value bit, or if a seed + /// offset reaches a route whose gain is not automated. + #[test] + fn the_automated_set_is_bits_4_to_11_and_only_an_automated_gain_takes_a_seed_offset() { + for bit in 0..12 { + let flags = RouteFollowFlags::from_word(1 << bit).expect("a defined bit"); + let set = flags.automated_set(); + let mut expected = RouteAutomatedSet::default(); + match bit { + 4 | 5 => expected.lanes[bit - 4] = true, + 6..=11 => expected.rows[bit - 6] = true, + _ => {} + } + assert_eq!(set, expected, "bit {bit}"); + } + + let curve = steps(&[0, 128], &[0.0, 1.0], 4_096); + let source = mute_source([Some(&curve), None], 7); + let program = std::sync::Arc::clone(&source.program); + let rows = |gain: bool| { + let flags = RouteFollowFlags { + automated_gain: gain, + automated_matrix: [true, false, false, false], + ..RouteFollowFlags::default() + }; + GraphFollowRouteSource::new( + std::sync::Arc::clone(&program), + [None, None], + [gain.then_some(0), None, Some(0), None, None, None], + 0, + route_jumps, + sum_law, + flags, + ) + .expect("a route-row source") + }; + assert_eq!(rows(true).gain_offset().to_bits(), 0.0_f32.to_bits()); + let seeded = rows(true) + .with_gain_offset(-3.5) + .expect("an automated gain"); + assert_eq!(seeded.gain_offset().to_bits(), (-3.5_f32).to_bits()); + assert!( + rows(true).with_gain_offset(f32::NAN).is_none(), + "not finite" + ); + assert!( + rows(false).with_gain_offset(-3.5).is_none(), + "a gain the plan does not automate" + ); + } + /// The cursor rows' jump lengths: the source mute and the route mute take the mute length, /// the gain the `SendGain` length and a matrix entry the `SendMatrix` length. /// diff --git a/crates/host-core/src/lib.rs b/crates/host-core/src/lib.rs index 02a8693a8..c51ef62aa 100644 --- a/crates/host-core/src/lib.rs +++ b/crates/host-core/src/lib.rs @@ -152,7 +152,7 @@ pub use prepare::{ LiveRampRow, LiveRamps, MAXIMUM_VCA_REACH_PAIRS, MAXIMUM_VCAS_BOUNDED, MonitorBase, 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, + count_effects, default_source_ring_frames, effect_producer_position, follow_route_producer_for, 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, diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index 6ad18fb5e..8d9dbb399 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -56,8 +56,8 @@ pub fn take_fast_classification_count() -> usize { use crate::prepare::{ 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, + input_records, level_differs, level_records_between, matrix_record, route_gain_offset, + route_record_between, route_source_strip, same_params, strip_at, }; use crate::prepare::{GraphJumpLengths, LiveRampRow, LiveRamps, MonitorBase, session_jump_lengths}; @@ -1026,10 +1026,16 @@ fn lower_routes<'a>( // #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. + // #1654 D6: a route that automates a row of its own takes the seven-word record too, with + // its automated gain's hand offset in the `gain` word, never the six-word form. let following = match following_route_lanes(next.structure(), after) { Some(automated) => Some(Following { terms: diff.terms(before, after).ok_or(LiveRebuild::Structure)?, automated, + gain_offsets: [ + route_gain_offset(ModelView::whole(current), &before.id), + route_gain_offset(next, &after.id), + ], }), None => None, }; @@ -1408,8 +1414,12 @@ struct RouteTarget<'m, 'a> { 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], + /// The source lanes and route rows the next plan automates (flags bits 4-11, #1654 D2). + automated: graph::RouteAutomatedSet, + /// The hand offset of the route's `gain_db`, in `[before, after]` (#1654 R17-6): the `gain` + /// word when the plan automates the gain. `after` is the view's, so an in-place edit of the + /// driving entry's offset (R17-1) reaches the record. + gain_offsets: [f32; 2], } /// What a strip lowering is of: a strip in both models, or one effect instance of it. @@ -1525,6 +1535,7 @@ fn route_records<'a>( target.after, following.terms, following.automated, + following.gain_offsets, target.gates, target.gate_length, target.edit, diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index a890c5a39..e2f9cd675 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -593,6 +593,21 @@ pub fn follow_route_producer_position( .ok() } +/// The position of the seven-word producer that may take `record` for route `id`: the producer +/// of [`follow_route_producer_position`], when the record carries the automated set its plan bound +/// (flags bits 4-11, [`graph::RouteFollowFlags::automated_set`], #1653 D5, #1654 D2). Render takes +/// that set from its own cursors, so a record whose set differs is an internal fault: the commit +/// refuses it before any write, and no ack precedes a drop. Allocation-free. +#[must_use] +pub fn follow_route_producer_for( + producers: &[graph::GraphFollowRouteControlProducer], + id: &str, + record: &graph::FollowRouteControlRecord, +) -> Option { + follow_route_producer_position(producers, id) + .filter(|&index| producers[index].automated() == record.flags().automated_set()) +} + /// 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 @@ -2874,31 +2889,40 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( // 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. + // #1654 D2: a route that automates one of its own rows 1-6 is a following route too, with its + // route cells and flags bits 6-11, and its automated gain's hand offset seeded in the `gain` + // word. let mut follow_cells = Vec::new(); for route in &model.routes { - if !follows_automated_mute(model, route) { + let Some(automated) = following_route_lanes(model, route) else { 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 { + let Some(source) = route_source_strip(model, route) 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()), - // #1654 T2: the plan gives a route no row cell yet, so no route row is automated. - automated_gain: false, - automated_mute: false, - automated_matrix: [false; 4], + // `following_route_lanes` and the builtins plan read the same rendered mute and route + // entries, so a following route always has the cells its set names, in the plan's one + // program. + let mute_cells = builtins.strip_mute_cells(route_source_id(route).as_str()); + let route_cells = builtins.route_cells(route.id.as_str()); + let (program, cells, rows) = match (mute_cells, route_cells) { + (Some((program, cells)), Some((other, rows))) + if std::sync::Arc::ptr_eq(&program, &other) => + { + (program, cells, rows) + } + (Some((program, cells)), None) => (program, cells, [None; graph::GRAPH_ROUTE_ROWS]), + (None, Some((program, rows))) => (program, [None; 2], rows), + _ => return Err(graph_failure("host.route_control.attach")), }; - follow_cells.push((route.id.as_str(), (program, cells, flags))); + if cells.map(|cell| cell.is_some()) != automated.lanes + || rows.map(|cell| cell.is_some()) != automated.rows + { + return Err(graph_failure("host.route_control.attach")); + } + let flags = follow_route_flags(route, effective.strips[source].mute, automated); + let offset = route_gain_offset(controls::ModelView::whole(model), &route.id); + follow_cells.push((route.id.as_str(), (program, cells, rows, flags, offset))); } let mut artifact = GraphCompiler::compile_with_builtins(GraphBuiltinsCompileRequest { dispatch: backend, @@ -2953,19 +2977,24 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( let mut sources: BTreeMap<&str, _> = follow_cells.into_iter().collect(); artifact .attach_route_live_controls_following(&live_superseded, six_word, |route_id| { - let (program, cells, flags) = sources.remove(route_id.as_str())?; + let (program, cells, rows, flags, offset) = sources.remove(route_id.as_str())?; let node = GraphNodeId::Route { route_id: route_id.clone(), }; - graph::GraphFollowRouteSource::new( + let source = graph::GraphFollowRouteSource::new( program, cells, - [None; graph::GRAPH_ROUTE_ROWS], + rows, graph::floored_input_arrival(&arrivals, &node), follow_route_jumps, graph_compiler::route_gain_law, flags, - ) + )?; + if flags.automated_gain { + source.with_gain_offset(offset) + } else { + Some(source) + } }) .map_err(|error| match error { graph::GraphRouteControlError::AlreadyAttached @@ -4365,35 +4394,52 @@ impl SendRecord { } } +/// The flags word of a following route (#1652 D1, #1654 D2): the route's `mute` and +/// `follows_mute`, the source lanes' `terms` and the plan's automated set (bits 4-11). +pub(crate) fn follow_route_flags( + route: &session::Route, + terms: [bool; 2], + automated: graph::RouteAutomatedSet, +) -> graph::RouteFollowFlags { + let [automated_gain, automated_mute, ll, lr, rl, rr] = automated.rows; + graph::RouteFollowFlags { + mute: route.mute, + follows_mute: route.follows_mute, + terms, + automated: automated.lanes, + automated_gain, + automated_mute, + automated_matrix: [ll, lr, rl, rr], + } +} + /// 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`. +/// with, so a record's words and the plan's are one bit pattern), the flags word +/// ([`follow_route_flags`]) and `length`. When the plan automates the route's `gain_db` (bit 6), +/// the `gain` word is the hand offset `gain_offset` in dB instead of the static gain, which is the +/// fallback and is not heard (#1654, batch 10 amendment); an automated matrix entry's word is its +/// static fallback, which render ignores. /// /// # Errors /// -/// [`LiveRebuild::Domain`] when the gain or matrix is outside the route domain, or `length` is -/// longer than the cell's maximum. +/// [`LiveRebuild::Domain`] when the gain or matrix is outside the route domain, the offset is not +/// finite, or `length` is longer than the cell's maximum. pub(crate) fn follow_route_record( route: &session::Route, terms: [bool; 2], - automated: [bool; 2], + automated: graph::RouteAutomatedSet, + gain_offset: f32, length: u32, ) -> Result { let matrix = &route.channel_matrix; - let transform = + let mut 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, - // #1654 T2: the plan gives a route no row cell yet, so no route row is automated. - automated_gain: false, - automated_mute: false, - automated_matrix: [false; 4], - }; + let flags = follow_route_flags(route, terms, automated); + if flags.automated_gain { + transform.gain = gain_offset; + } graph::FollowRouteControlRecord::new(transform, flags, length).ok_or(LiveRebuild::Domain) } @@ -4403,15 +4449,20 @@ pub(crate) fn follow_route_record( /// 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 +/// after]` (`controls::effective`, OQ1 = C); `automated` is the next plan's; `gain_offsets` is the +/// hand offset of the route's `gain_db` per model, in `[before, after]` ([`route_gain_offset`]), +/// read only when the plan automates the gain. A record is built only when a word render hears +/// changes: the `gain` word (the offset when the gain is automated, else the static gain), an +/// un-automated matrix entry, `mute`, `follows_mute` or `terms`. A word of an automated matrix row +/// is the static fallback, which render ignores, so a change of only such words (or of the static +/// gain under an automated gain) gives no record and commits `model_only`: automation wins (#1654 +/// D4). `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. +/// curve can make that a change of target), else the longest of the changed `SendGain` (the +/// offset under an automated gain, R17-6) and `SendMatrix` lengths, else the `SendMute` length. /// /// # Errors /// @@ -4422,14 +4473,18 @@ pub(crate) fn follow_route_record_between( before: &session::Route, after: &session::Route, terms: [[bool; 2]; 2], - automated: [bool; 2], + automated: graph::RouteAutomatedSet, + gain_offsets: [f32; 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 prior = follow_route_record(before, terms[0], automated, gain_offsets[0], 0)?; + let current = follow_route_record(after, terms[1], automated, gain_offsets[1], 0)?; + // The words render hears: an automated matrix entry's word is masked (`None`). + let [_, _, ll, lr, rl, rr] = automated.rows; + let masked = [false, ll, lr, rl, rr]; + let heard_bits = |record: &graph::FollowRouteControlRecord| { let graph::RouteTransform { gain, ll, @@ -4437,30 +4492,28 @@ pub(crate) fn follow_route_record_between( rl, rr, } = record.transform(); - [gain, ll, lr, rl, rr].map(f32::to_bits) + let words = [gain, ll, lr, rl, rr]; + core::array::from_fn::<_, 5, _>(|word| (!masked[word]).then(|| words[word].to_bits())) }; - if transform_bits(&prior) == transform_bits(¤t) && prior.flags() == current.flags() { + let (prior_bits, current_bits) = (heard_bits(&prior), heard_bits(¤t)); + if prior_bits == current_bits && 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()) + let gain = (prior_bits[0] != current_bits[0]) .then(|| ramps.resolve(LiveRampRow::SendGain, edit.gain)); - let matrix = (matrix(before) != matrix(after)) + let matrix = (prior_bits[1..] != current_bits[1..]) .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) + follow_route_record(after, terms[1], automated, gain_offsets[1], length).map(Some) } /// A fader or pan value outside the domain its render-side setter accepts. @@ -5075,23 +5128,26 @@ fn route_source_id(route: &session::Route) -> &session::StableId { } } -/// 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. +/// Whether a plan of `model` gives `route` the seven-word cell of a following route (#1652 D1, +/// #1654 D2): it goes into a submix and either its source strip automates the mute of a lane +/// ([`controls::mute_automation`], the entries the builtins plan compiles mute cells from) or the +/// route automates one of its own rows 1-6 ([`controls::route_automation`], the entries the +/// builtins plan compiles route 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 { 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). +/// What a plan of `model` automates of `route` (flags bits 4-11: the source lanes whose mute has +/// a cell and the route rows 1-6 that have one), when that plan gives `route` the seven-word cell +/// of a following route ([`follows_automated_mute`]), else `None`: the automated set of the +/// route's records (#1652 D1, #1653 D1, #1654 D2). #[must_use] pub(crate) fn following_route_lanes( model: &SessionModel, route: &session::Route, -) -> Option<[bool; 2]> { +) -> Option { if !matches!( route.destination, session::RouteDestination::SubmixInput { .. } @@ -5099,9 +5155,21 @@ pub(crate) fn following_route_lanes( { return None; } - let automated = - controls::mute_automation(model, route_source_id(route)).map(|entry| entry.is_some()); - (automated != [false; 2]).then_some(automated) + let set = graph::RouteAutomatedSet { + lanes: controls::mute_automation(model, route_source_id(route)) + .map(|entry| entry.is_some()), + rows: controls::route_automation(model, &route.id).map(|entry| entry.is_some()), + }; + (set.lanes.contains(&true) || set.rows.contains(&true)).then_some(set) +} + +/// The hand offset in dB of route `route`'s automated `gain_db` (row 1) as `view` holds it (#1654 +/// R17-6): the driving entry's `offset.left_db` (a shared row's two fields are equal, the +/// validator's `control.automation.offset`), or `+0.0` when no entry drives the row. A following +/// route's cell holds it in its `gain` word when the plan automates the gain. +#[must_use] +pub(crate) fn route_gain_offset(view: controls::ModelView<'_>, route: &session::StableId) -> f32 { + view.route_automation(route)[0].map_or(0.0, |entry| entry.offset.left_db) } /// The jump lengths of a following route's cursor rows (#1652 D2, #1652b, #1654 D1): the row @@ -5830,6 +5898,9 @@ fn resolve_strip_delay_carry( struct RouteJoin<'a> { id: &'a str, retarget: Option, + /// The route rows 1-6 both models automate with the same table (#1654 D3, + /// [`controls::continued_route_rows`]): the move's [`graph::GraphRouteMove::continued_rows`]. + continued_rows: [bool; graph::GRAPH_ROUTE_ROWS], } /// The model half of the send join (#1284 D2, D3): for every route of `model` into a submix that @@ -5886,18 +5957,20 @@ fn join_routes<'a>( // #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 retarget = if let Some(automated) = following_route_lanes(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, + [ + route_gain_offset(controls::ModelView::whole(committed), &before.id), + route_gain_offset(controls::ModelView::whole(model), &after.id), + ], gates, gate_length, edit, @@ -5913,6 +5986,7 @@ fn join_routes<'a>( joins.push(RouteJoin { id: after.id.as_str(), retarget, + continued_rows: controls::continued_route_rows(committed, model, &after.id), }); } joins @@ -5960,6 +6034,7 @@ fn resolve_route_carry<'a>( successor: after, predecessor: before, predecessor_follows: follows, + continued_rows: join.continued_rows, }); if let Some(record) = join.retarget { carry.retargets.push((join.id, record)); @@ -6716,20 +6791,25 @@ mod tests { RouteJoin { id: "a-main", retarget: Some(record), + continued_rows: [false; graph::GRAPH_ROUTE_ROWS], }, + // #1654 D3: the join's continued rows reach the move as they are. RouteJoin { id: "b-main", retarget: None, + continued_rows: [true, false, false, false, false, true], }, // Present in the successor only. RouteJoin { id: "c-main", retarget: Some(record), + continued_rows: [false; graph::GRAPH_ROUTE_ROWS], }, // Present in the predecessor's model but not a live route of either plan. RouteJoin { id: "d-main", retarget: Some(record), + continued_rows: [false; graph::GRAPH_ROUTE_ROWS], }, ]; let carry = resolve_route_carry(&before, &own, &joins); @@ -6740,11 +6820,13 @@ mod tests { successor: 4, predecessor: 3, predecessor_follows: false, + continued_rows: [false; graph::GRAPH_ROUTE_ROWS], }, graph::GraphRouteMove { successor: 2, predecessor: 5, predecessor_follows: false, + continued_rows: [true, false, false, false, false, true], }, ] ); @@ -6771,7 +6853,11 @@ mod tests { unit, following, }; - let join = |id| RouteJoin { id, retarget: None }; + let join = |id| RouteJoin { + id, + retarget: None, + continued_rows: [false; graph::GRAPH_ROUTE_ROWS], + }; // (case, predecessor following, successor following, flagged) for (case, before, after, flagged) in [ ("six -> six", false, false, false), diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 771a1f8cd..9fd15e93a 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -5381,3 +5381,300 @@ fn a_following_routes_edits_write_its_seven_word_cell_only() { let delta = following_delta(¤t, &next, &[]); assert!(delta.is_empty(), "an identical rewrite writes nothing"); } + +// ---- #1654 T4: a send's own automated rows ----------------------------------------------------- + +/// The position of [`with_bus`]'s send (the first track's route into `bus`). +fn send_of(model: &SessionModel) -> usize { + model + .routes + .iter() + .position(|route| matches!(route.destination, RouteDestination::SubmixInput { .. })) + .expect("the send into bus") +} + +/// One stored entry on route row `control` of route `route`, on `both`: a linear ride for the gain +/// (row 1, dB) and the matrix (rows 3-6), a step for the route's own mute (row 2). +fn send_entry(route: &StableId, control: u32) -> Automation { + let (shape, from, to, unit) = match control { + 1 => (AutomationShape::Linear, -6.0, -2.0, ParameterUnit::Db), + 2 => (AutomationShape::Step, 0.0, 1.0, ParameterUnit::Linear), + _ => (AutomationShape::Linear, 0.25, 0.5, ParameterUnit::Linear), + }; + Automation { + id: id(&format!("send-row-{control}")), + target: ControlAddress { + owner: Owner::Route(route.clone()), + section: Section::Own, + control, + lane: ParameterChannel::Both, + }, + segments: vec![AutomationSegment { + shape, + start_sample: 0, + end_sample: 4_096, + start_value: from, + end_value: to, + unit, + }], + offset: session::AutomationOffset::default(), + } +} + +/// [`with_bus`]`(false)` with distinct session lengths for the three send rows (mute 10 ms, gain +/// 5 ms, matrix 2.5 ms) and one stored entry on each route row of `rows` of its send: a route +/// whose source strip automates nothing. +fn with_send_rows(rows: &[u32]) -> SessionModel { + let mut model = with_bus(false); + model.control_smoothing = Some(ControlSmoothing { + mute_ms: 10.0, + fader_ms: 5.0, + pan_ms: 2.5, + }); + let send = model.routes[send_of(&model)].id.clone(); + for &control in rows { + model.automation.push(send_entry(&send, control)); + } + normalized(&model) +} + +/// Route row `control` of `model`'s send, on `both`. +fn send_row(model: &SessionModel, control: u32) -> ControlAddress { + ControlAddress { + owner: Owner::Route(model.routes[send_of(model)].id.clone()), + section: Section::Own, + control, + lane: ParameterChannel::Both, + } +} + +/// `[gain, ll, lr, rl, rr]` of a transform, as bits. +fn transform_words(transform: graph::RouteTransform) -> [u32; 5] { + [ + transform.gain, + transform.ll, + transform.lr, + transform.rl, + transform.rr, + ] + .map(f32::to_bits) +} + +/// #1654 D3 (T4). A send automation edit needs a rebuild: an entry added on route rows 1-6 of a +/// send, one given another segment table and one removed are each `LiveRebuild::Automation`. +/// +/// Test value: red if a route entry change is lowered as a live value (the plan's route cells are +/// compiled from those entries, so a live commit would ack a curve nobody hears). +#[test] +fn a_send_automation_edit_is_a_rebuild() { + let plain = with_send_rows(&[]); + for control in 1..=6 { + let added = edited(&plain, |next| { + let send = next.routes[send_of(next)].id.clone(); + next.automation.push(send_entry(&send, control)); + }); + assert_eq!( + classify_live_delta(&plain, &added, STEP, &EditRamps::new()).map(|_| ()), + Err(LiveRebuild::Automation), + "row {control} added" + ); + } + let automated = with_send_rows(&[1, 4]); + let moved = edited(&automated, |next| { + next.automation[0].segments[0].end_value = -3.0; + }); + let removed = edited(&automated, |next| { + next.automation.remove(1); + }); + for (label, next) in [("other segments", moved), ("removed", removed)] { + assert_eq!( + classify_live_delta(&automated, &next, STEP, &EditRamps::new()).map(|_| ()), + Err(LiveRebuild::Automation), + "{label}" + ); + } +} + +/// #1654 R17-6 and D4 (T4). On a send whose `gain_db` and `matrix_lr` stored entries render: +/// +/// - a hand move of the gain (the entry's offset to `+3`, what `SET_CONTROL` row 1 writes) gives +/// one seven-word record whose `gain` word is the offset `+3` dB (not a linear gain), whose flags +/// carry bits 6 and 9 and no source lane, over the `SendGain` length (and the edit's own length +/// when it gives one); +/// - a static edit of the automated `matrix_lr` (the fallback) gives no record: the delta is empty, +/// so the commit is `model_only`; +/// - a live edit of the un-automated `matrix_ll` and of the route's own mute (D6) each give one +/// seven-word record, never a folded one, over the `SendMatrix` and `SendMute` lengths, with the +/// offset still in the `gain` word. +/// +/// Every case gives the same delta through the fast path over the row it names. +/// +/// Test value: red if a hand gain move writes the static fallback or a linear gain into word 0, +/// if the offset reaches a matrix word, if a hand edit of an automated matrix row is acked live +/// (automation wins), or if an un-automated row of an automated send is written in the six-word +/// form. +#[test] +fn a_sends_hand_edits_lower_its_offset_and_its_unautomated_rows_only() { + let current = with_send_rows(&[1, 4]); + let send = send_of(¤t); + let ramps = LiveRamps::for_session(¤t); + let expected_flags = graph::RouteFollowFlags { + automated_gain: true, + automated_matrix: [false, true, false, false], + ..graph::RouteFollowFlags::default() + }; + + // The hand offset. + let next = edited(¤t, |next| { + next.automation[0].offset = session::AutomationOffset { + left_db: 3.0, + right_db: 3.0, + }; + }); + let delta = following_delta(¤t, &next, &[send_row(¤t, 1)]); + assert!(delta.strips.is_empty()); + let records = seven_word(&delta); + assert_eq!(records.len(), 1, "one seven-word record"); + let record = records[0].1; + assert_eq!(record.flags(), expected_flags, "bits 6 and 9 only"); + let matrix = &next.routes[send].channel_matrix; + let mut expected = graph_compiler::route_values( + next.routes[send].gain_db, + [matrix.ll, matrix.lr, matrix.rl, matrix.rr], + ) + .expect("in domain"); + expected.gain = 3.0; + assert_eq!( + transform_words(record.transform()), + transform_words(expected), + "word 0 is the offset in dB; the matrix words are the route's" + ); + assert_eq!( + record.length(), + ramps.resolve(LiveRampRow::SendGain, None), + "the SendGain length" + ); + let mut own = EditRamps::new(); + own.set( + &EditRampTarget::Route(next.routes[send].id.clone()), + EditRampRow::RouteGain, + Some(77), + ); + let with_own = classify_live_delta(¤t, &next, ramps, &own).expect("live"); + assert_eq!( + seven_word(&with_own)[0].1.length(), + 77, + "the edit's own length" + ); + + // The automated matrix row's fallback: automation wins. + let next = edited(¤t, |next| next.routes[send].channel_matrix.lr = 0.75); + let delta = following_delta(¤t, &next, &[send_row(¤t, 4)]); + assert!(delta.is_empty(), "no record: the commit is model_only"); + + // D6: the un-automated `matrix_ll` and the route's own mute. + for (label, control, row) in [ + ("matrix_ll", 3, LiveRampRow::SendMatrix), + ("mute", 2, LiveRampRow::SendMute), + ] { + let next = edited(¤t, |next| match control { + 3 => next.routes[send].channel_matrix.ll = 0.5, + _ => next.routes[send].mute = true, + }); + let delta = following_delta(¤t, &next, &[send_row(¤t, control)]); + let records = seven_word(&delta); + assert_eq!(records.len(), 1, "{label}: one seven-word record"); + let record = records[0].1; + assert_eq!( + record.flags(), + graph::RouteFollowFlags { + mute: control == 2, + ..expected_flags + }, + "{label}" + ); + let matrix = &next.routes[send].channel_matrix; + let mut expected = graph_compiler::route_values( + next.routes[send].gain_db, + [matrix.ll, matrix.lr, matrix.rl, matrix.rr], + ) + .expect("in domain"); + expected.gain = 0.0; + assert_eq!( + transform_words(record.transform()), + transform_words(expected), + "{label}: the new values, the offset in word 0" + ); + assert_eq!(record.length(), ramps.resolve(row, None), "{label}"); + } +} + +/// #1654 D2 (T4). A send with a rendered route entry and no source mute curve gets the seven-word +/// lane, its producer binds the plan's automated set (bits 6 and 9 here, no source lane), and a +/// send with no rendered entry keeps the six-word lane. `follow_route_producer_for` (the commit's +/// pre-write check) takes the classifier's record for that send and refuses one whose automated +/// set differs in any bit, leaving the cell unwritten. +/// +/// Test value: red if a route-row-only send keeps the six-word lane (render cannot hear its +/// curve), if the attach seeds flags bits 6-11 that disagree with the plan's cells, or if the +/// pre-write check compares fewer bits than the whole set (a record whose route rows disagree +/// would be written and acked while render ignores it). +#[cfg(feature = "test-support")] +#[test] +fn a_route_row_send_binds_its_set_and_the_commit_refuses_another() { + let lanes = |model: &SessionModel| { + host_core::prepare_host_runtime_with_live_lanes( + &compile(model), + &caps(), + &HostLiveControlRequest::default(), + host_core::HostLiveLanes::ALL, + &host_core::LiveValuesSuperseded::default(), + ) + .unwrap_or_else(|failure| { + panic!("prepare: {}", String::from_utf8_lossy(failure.as_bytes())) + }) + .1 + }; + let plain = lanes(&with_send_rows(&[])); + assert!(plain.follow_route_controls.is_empty(), "no seven-word lane"); + + let current = with_send_rows(&[1, 4]); + let handles = lanes(¤t); + let producers = &handles.follow_route_controls; + assert_eq!(producers.len(), 1, "the send's seven-word lane"); + let send_id = current.routes[send_of(¤t)].id.as_str(); + assert_eq!(&*producers[0].route_id, send_id); + assert_eq!( + producers[0].automated(), + graph::RouteAutomatedSet { + lanes: [false; 2], + rows: [true, false, false, true, false, false], + } + ); + + let next = edited(¤t, |next| { + next.automation[0].offset = session::AutomationOffset { + left_db: 3.0, + right_db: 3.0, + }; + }); + let delta = following_delta(¤t, &next, &[send_row(¤t, 1)]); + let record = seven_word(&delta)[0].1; + assert_eq!( + host_core::follow_route_producer_for(producers, send_id, &record), + Some(0) + ); + for bit in 4..12 { + let word = record.flags().to_word() ^ (1 << bit); + let flags = graph::RouteFollowFlags::from_word(word).expect("a defined bit"); + let other = + host_core::FollowRouteControlRecord::new(record.transform(), flags, record.length()) + .expect("in bounds"); + assert_eq!( + host_core::follow_route_producer_for(producers, send_id, &other), + None, + "bit {bit} flipped" + ); + } + assert_eq!(producers[0].test_only_write_count(), 0, "nothing written"); +} diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index c027a58d2..aba371183 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -6095,3 +6095,108 @@ fn solo_and_a_vca_mute_compose_with_the_curve_for_a_following_send_on_both_hosts } } } + +/// #1654 T4: [`follow_model`]`(false, false)` (no source mute curve) with the send `t1-b` riding +/// its `gain_db` (route row 1) linearly from 0 to -12 dB over the run, and `change` applied: a +/// route-row-only send, which the plan gives the seven-word cell. +#[cfg(feature = "test-support")] +fn send_ride_model(change: impl FnOnce(&mut SessionModel)) -> SessionModel { + let mut model = follow_model(false, false); + model.automation.push(session::Automation { + id: id("t1-b-gain-ride"), + target: address( + Owner::Route(id("t1-b")), + Section::Own, + 1, + ParameterChannel::Both, + ), + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: 0, + end_sample: u64::from(QUANTUM) * BLOCKS, + start_value: 0.0, + end_value: -12.0, + unit: session::ParameterUnit::Db, + }], + offset: session::AutomationOffset::default(), + }); + change(&mut model); + model +} + +/// #1654 R17-6 and root's ruling R17-1 on both hosts. On `t1-b`, whose `gain_db` stored entry +/// renders, `SET_CONTROL gain_db = +3` is `live` alike on both hosts, one revision, with one +/// reply; the committed document stores `+3` as the entry's offset on both fields and keeps the +/// static gain. The in-place leg commits in place on both hosts; the same transaction by the full +/// path (`control_plane::test_force_full_preparation`) gives the same reply, document and bits of +/// every block, alike on both hosts. +/// +/// Test value: red if the in-place path answers or renders otherwise than the full path for the +/// send gain (an owners model without the route entry writes the static gain or commits +/// `model_only`), or if the two hosts disagree. +#[cfg(feature = "test-support")] +#[test] +fn a_hand_move_on_an_automated_send_gain_is_an_offset_alike_on_both_hosts() { + const EDIT: u64 = 4; + const END: u64 = 20; + let model = send_ride_model(|_| {}); + let mut expected = send_ride_model(|model| { + model.automation[0].offset = session::AutomationOffset { + left_db: 3.0, + right_db: 3.0, + }; + }); + expected.revision = model.revision + 1; + let edits = [own_edit(Owner::Route(id("t1-b")), 1, 3.0)]; + let leg = |label: &str| { + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + same_blocks(&mut browser, &mut c_abi, 0..EDIT, label); + let (b, reply) = browser.transaction_reply(&edits); + let (c, c_reply) = c_abi.transaction(&edits); + assert_eq!(b, c, "{label}: one verdict"); + assert_eq!(reply, c_reply, "{label}: one reply"); + assert_eq!( + b, + Verdict::Applied { + path: EditPath::Live, + revision: model.revision + 1 + }, + "{label}: a hand move is live" + ); + let committed = snapshot_bytes(|payload, expected| browser.command(payload, expected)); + let blocks: Vec> = (EDIT..END) + .map(|block| { + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{label}: block {block} alike on both hosts"); + b + }) + .collect(); + (reply, committed, blocks) + }; + control_plane::test_take_in_place_counts(); + let in_place = leg("in place"); + assert_eq!( + control_plane::test_take_in_place_counts(), + (2, 0), + "both hosts commit the hand move in place" + ); + assert_eq!( + in_place.1, + canonical_session_json(&expected) + .expect("canonical") + .as_bytes(), + "the entry stores +3 on both fields and the static gain stays" + ); + control_plane::test_force_full_preparation(true); + let full = leg("full path"); + control_plane::test_force_full_preparation(false); + assert_eq!( + control_plane::test_take_in_place_counts().0, + 0, + "the full leg commits nothing in place" + ); + assert_eq!(full.0, in_place.0, "one reply on both paths"); + assert_eq!(full.1, in_place.1, "one document"); + assert_eq!(full.2, in_place.2, "the same bits on both paths"); +} From 08c0a251fa8bfce6849194afc639b0ceb312d849 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 13:56:12 +0000 Subject: [PATCH 13/36] Render send row automation in the following route op on the shared walk (#1654 T3) The following route op is rewritten on K1: each source column walks its curves (source mutes, the route's own mute, gain, its matrix entry) and composes one target (gain law, matrix, gate) on the per-column ramps. A column jump holds its value events and lands them over their own length at the jump's end (R19-3); a held value follows the column's current free time when a later jump replaces the ramp; coincident events take the longest jump (R19-4); a continued row carries its cursor verbatim; a hand move in flight at a Reposition keeps its length (R19-5). Cursor tables are charged by size_of. Realtime policy 171. Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 65 + crates/automation/src/compose.rs | 91 +- crates/graph/src/lib.rs | 50 +- crates/graph/src/runtime.rs | 1798 ++++++++++++++--- 4 files changed, 1676 insertions(+), 328 deletions(-) diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index c07da5adf..47b8a161a 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -544,3 +544,68 @@ retarget in between, landing at 1,480 over 256 to the curve at 1,735, grid again `a_deferral_holds_the_grid_until_it_fires`, `a_caller_retarget_holds_the_value_grid_until_its_end`, and `tests/allocation.rs::the_merged_walk_allocates_nothing` (10,000 blocks, seeks, retargets, deferrals: 0 allocations, 0 frees). Realtime policy: 171 marked regions in 35 files (170 before). + +## Decision record: T3 (batch 19) + +`crates/graph` (the route op, its cursors, the route carry, the source and the charge) and +`crates/automation/src/compose.rs` (K1, two additions). No six-word route code, byte or bit moved. +Nothing under `crates/engine/src/realtime/`; no `crates/lane` kernel change. + +- **Route op on K1.** `LiveFollowRoute` holds one `RouteColumn` per source column that hears a + curve: a `Composed<([f32; 2], bool)>` walk over that column's cursors (source mute of its lane + and the route's own mute as `Gate`, gain and the column's two matrix entries as `Value`, each + with its row's jump length per block, `GraphRouteJumpLengths::for_row`), a probe twin for the + block entry's activity probe, its roles and its carry placement. The two columns' events are + merged in offset order (`walk_columns`, over K1's new pull form `start`/`next_event`). One + composition, `column_target`: `law(curve, word 0)` when the plan automates the gain, each + automated matrix entry from its curve, gate `mute = static mute || route mute curve` (D5) and the + column's follow term, `gated_route_coefficients`, the column's entries. A column's entries do not + depend on the other column's gate, so the columns compose apart; the route's silence is both + columns silent. Retarget per column over the event's own ramp (`ColumnRamps::retarget_column`, + replacing `retarget`; `route_mix_column_ramps_block` unchanged). Every #1652 graph test is green + unchanged on the new op. +- **Records.** A record that changes a value word takes each automated value toward the record + ramp's completion (`Composed::compose_toward`; gates as heard) and a gate-only record composes + with the heard values; a column the record moves is a column jump (`Composed::retarget`: its + value cursors defer to the ramp's end, R19-3). A predecessor's last read at a swap composes with + the heard values and defers nothing (it is retired). +- **Carry (D3).** `GraphFollowRouteSource::with_continued_rows([bool; 6])` (new, default none; + T4 sets it from `continued_route_rows`). Seven -> seven: per column and role the predecessor's + cursor state and heard value are copied; a continued row is kept verbatim, any other cursor is + repositioned at the successor's first node time (its target stays the one the column heard; a + row the predecessor lacked starts with no target). At the first block: equal column bits leave + the ramp; a moved gate retargets the column at once over the mute length (a column jump); moved + values alone defer the placed value cursors to the column ramp's first free sample, at once when + settled (R19-5, #1711 D1: exact, no flag). Seven -> six keeps `heard_zeroed` over the source + mute cursors. +- **Coordinator question (B lands at an old free time inside A's jump): it can happen, fixed.** + K1 now extends a value cursor's pending deferral to the end of a column ramp that another + cursor's retarget replaced and outlasts (never shortens; the cursor whose own event it is keeps + its cell's rule, so K = 1 stays exactly `CellEvents`: all K1 gates green). Gate: + `a_held_value_waits_for_the_end_of_a_jump_that_replaced_the_column_ramp` (red without the + extension: block 18). Compatible with the U1 fold to `next_in_run` (a cursor's own discontinuity + jump clears its own deferral). `events.rs` is not edited by T3. +- **K1 additions.** Pull form (`start`, `next_event`; `walk` is built on them); `compose_toward` + reads `Gate` cursors as heard and repositions a copy of each value cursor's state at `start`. +- **Charge.** The producer carries `cursor_bytes` (per column with a curve, two tables of + `Composed::bytes(K)`), added to `owner_bytes` and to the largest allocation. +- **Departure: no graph-level allocation gate.** Linking `bench_support`'s audited allocator into + graph's lib test binary makes an existing test abort + (`runtime::tests::a_folded_metered_plan_is_the_unfolded_plans_master_and_meters_bit_for_bit`, + green without the allocator). The walk's gate is K1's + (`automation/tests/allocation.rs::the_merged_walk_allocates_nothing`); host-core's + `route_mute.rs` render gates run the new op for mute-only routes; the send-row render gate is T4. + +Gates (`cargo test --locked -p graph --features test-support`, 130 lib tests green), module +`runtime::send_automation_tests`: `a_flat_send_ride_mixes_the_static_route_bits` (offsets `+0.0` +and `-0.0`, 40 blocks bit-equal), `a_gain_ride_across_an_unmute_fade_ends_the_fade_at_its_length_and_lands_at_its_end`, +`a_ride_on_the_left_column_keeps_moving_while_the_right_column_fades`, +`coincident_send_events_take_the_longest_jump`, `a_hand_offset_record_aims_at_the_curve_at_its_end_and_holds_the_grid`, +`a_held_value_waits_for_the_end_of_a_jump_that_replaced_the_column_ramp`, +`a_continued_send_ride_carries_its_cursor_verbatim` (red with no row continued), +`a_moved_send_table_jumps_over_its_own_length_at_the_first_sample`, +`a_hand_move_in_flight_at_a_swap_keeps_its_length_and_the_moved_table_lands_after_it`. The route +op mixes at `FrameLane` on every backend: no bank width applies. host-core `--features +test-support`: all green. Sizes (x86-64): `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 360 -> 944, +`GraphFollowRouteSource` 112 -> 120, `GraphFollowRouteControlProducer` 104, `ComposeCursor` 104. +Realtime policy: 171 regions in 35 files. diff --git a/crates/automation/src/compose.rs b/crates/automation/src/compose.rs index a4781b3ff..d6fabd67b 100644 --- a/crates/automation/src/compose.rs +++ b/crates/automation/src/compose.rs @@ -121,6 +121,8 @@ pub struct ComposeCursor { shown: f32, /// Whether the current offset merged an event of this cursor. consumed: bool, + /// Whether that event was not held (the cursor's own retarget). + moved: bool, } impl ComposeCursor { @@ -212,6 +214,8 @@ pub struct Composed { state: CellState, target: Option, completion: Completion, + /// The block begun by [`Self::start`] and its seek mode, until its events end. + block: Option<(NodeSpan, SeekMode)>, } impl Composed { @@ -260,6 +264,7 @@ impl Composed { next: None, shown: seed, consumed: false, + moved: false, }); } Ok(Self { @@ -267,6 +272,7 @@ impl Composed { state: CellState::new(), target: None, completion, + block: None, }) } } @@ -294,11 +300,19 @@ impl Heard<'_> { match self.source { Source::Heard => cursor.heard, Source::Shown => cursor.shown, - Source::Toward { program, start, n } => program - .cell(cursor.cell as usize) - .map_or(cursor.heard, |cell| { - target_toward(&cell, &cursor.state, start, n) - }), + // A gate is heard as it is: its value holds until its next jump. + Source::Toward { .. } if cursor.kind == CursorKind::Gate => cursor.heard, + Source::Toward { program, start, n } => { + program + .cell(cursor.cell as usize) + .map_or(cursor.heard, |cell| { + // Read as placed at `start` (a fresh output's first block has not placed + // its cursors yet): the next discontinuity after `start` clips the value. + let mut state = cursor.state; + state.reposition(&cell, start); + target_toward(&cell, &state, start, n) + }) + } } } } @@ -346,9 +360,10 @@ impl Composed { compose(self.heard(Source::Heard)) } - /// The composition of every curve toward the completion of a ramp of `n` samples from node - /// time `start` (the curve at that completion, never past its next discontinuity): the target - /// of a record that retargets the output over its own ramp (#1649 D3 on a composed output). + /// The composition of every value curve toward the completion of a ramp of `n` samples from + /// node time `start` (the curve at that completion, never past its next discontinuity), with + /// every gate as heard: the target of a record that retargets the output over its own ramp + /// (#1649 D3 on a composed output). /// Reads the cursors' positions, so call it at a block entry, after the block's placement. pub fn compose_toward( &self, @@ -416,6 +431,7 @@ impl Composed { self.state = other.state; self.target = other.target; self.completion = other.completion; + self.block = other.block; true } @@ -434,6 +450,7 @@ impl Composed { /// The events of the block `span` in increasing offset, at most one per offset, each passed /// to `emit`; `compose` turns the heard values into the output's target. Every cursor's state /// advances through the block. A cursor whose cell the program does not hold gives no event. + /// [`Self::start`] then [`Self::next_event`] until `None`. pub fn walk( &mut self, program: &Program, @@ -442,15 +459,36 @@ impl Composed { mut compose: impl FnMut(Heard<'_>) -> T, mut emit: impl FnMut(ComposedEvent), ) { + self.start(program, span, mode); + while let Some(event) = self.next_event(program, &mut compose) { + emit(event); + } + } + + /// Begins the block `span`: each cursor pulls its first event. The block's events then come + /// out of [`Self::next_event`], one at a time, so a caller can merge several outputs' events + /// in offset order (a route's two columns). + pub fn start(&mut self, program: &Program, span: NodeSpan, mode: SeekMode) { let frames = span.frames(); + self.block = Some((span, mode)); for cursor in self.cursors.iter_mut() { cursor.next = None; cursor.consumed = false; cursor.shown = cursor.heard; pull(cursor, program, &span, mode, 0, frames); } - // Each pass merges at least one pulled event, and a cursor gives a bounded number per - // block, so the loop ends. + } + + /// The block's next event, or `None` at its end (or with no block begun): every offset with + /// cursor events is merged and decided in order until one gives the output an event. Bounded: + /// each merged offset consumes at least one cursor event. + pub fn next_event( + &mut self, + program: &Program, + mut compose: impl FnMut(Heard<'_>) -> T, + ) -> Option> { + let (span, mode) = self.block?; + let frames = span.frames(); while let Some(offset) = self .cursors .iter() @@ -467,22 +505,36 @@ impl Composed { // not (every cursor landed itself). let column_jump = merged.gate_jump && !landing && event.is_some_and(|event| !event.held); - let defer = if column_jump { + let retargeted = !landing && event.is_some_and(|event| !event.held); + let free = if retargeted { self.state.free_after(self.completion) } else { None }; - if let Some(event) = event { - emit(event); - } let next = offset.saturating_add(1); for cursor in self.cursors.iter_mut() { cursor.shown = cursor.heard; let value = cursor.kind == CursorKind::Value; + // A value cursor deferred to the end of a column ramp that this retarget replaced + // and outlasts (another curve's own jump) waits for the new ramp's end instead: + // its landing never cuts a ramp short (R19-3). The cursor whose own event this + // is follows its own cell's rules. + let pending = if cursor.consumed { + cursor.state.deferred_jump() + } else { + cursor.before.deferred_jump() + }; + let extend = free.filter(|&free| { + !column_jump + && !cursor.moved + && pending.is_some_and(|at| at > time && at < free) + }); + let defer = if column_jump { free } else { extend }; if let (Some(free), true) = (defer, value) { if !cursor.consumed { - // The cursor pulled past this offset before the column jump: replay its - // pull up to here (it gave no event there), then defer and pull again. + // The cursor pulled past this offset before the column ramp changed: + // replay its pull up to here (it gave no event there), then defer and + // pull again. replay(cursor, program, &span, mode, next); } cursor.state.defer_jump(free); @@ -491,8 +543,14 @@ impl Composed { pull(cursor, program, &span, mode, next, frames); } cursor.consumed = false; + cursor.moved = false; + } + if event.is_some() { + return event; } } + self.block = None; + None } /// The output's decision at `offset` (node time `time`) over the events `merged` there; @@ -578,6 +636,7 @@ fn merge(cursors: &mut [ComposeCursor], offset: u32) -> Merged { }; cursor.next = None; cursor.consumed = true; + cursor.moved = !event.held; cursor.shown = event.value; if event.held { merged.held = true; diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index e9023aed9..d25e10110 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1465,19 +1465,14 @@ pub type GraphRouteGainLaw = fn(curve_db: f32, offset_db: f32) -> f32; pub struct GraphFollowRouteSource { pub(crate) program: std::sync::Arc, pub(crate) cells: [Option; 2], - #[expect( - dead_code, - reason = "#1654 T2: the route op's cursor table (T3) reads the route's own row cells" - )] pub(crate) route_cells: [Option; GRAPH_ROUTE_ROWS], pub(crate) arrival: u64, pub(crate) jumps: GraphRouteJumpRule, - #[expect( - dead_code, - reason = "#1654 T2: the route op's gain composition (T3) calls the route's gain law" - )] pub(crate) gain_law: GraphRouteGainLaw, pub(crate) flags: RouteFollowFlags, + /// Per route row, whether the predecessor plan holds the same table for it, so a carry keeps + /// its cursor verbatim ([`Self::with_continued_rows`]). + pub(crate) continued: [bool; GRAPH_ROUTE_ROWS], } impl GraphFollowRouteSource { @@ -1518,10 +1513,23 @@ impl GraphFollowRouteSource { jumps, gain_law, flags, + continued: [false; GRAPH_ROUTE_ROWS], }, ) } + /// This source with `rows[row]` set for each route row (order [`GRAPH_ROUTE_ROWS`]) whose + /// table the predecessor plan holds unchanged (#1654 D3, the Carry paragraph): at a plan swap + /// that row's cursor carries its event state and heard value verbatim, where every other + /// cursor is placed again at the successor's node time and jumps over its own row's length if + /// its curve moved. A row the plan does not automate ignores its entry. By default no row + /// continues. + #[must_use] + pub const fn with_continued_rows(mut self, rows: [bool; GRAPH_ROUTE_ROWS]) -> Self { + self.continued = rows; + self + } + /// `a(tap)`, the arrival the route's events are timed at. #[must_use] pub const fn arrival(&self) -> u64 { @@ -1568,6 +1576,10 @@ pub struct GraphFollowRouteControlProducer { /// 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], + /// The bytes of the route op's cursor tables bind allocates (#1654 T3, R19-3: per column with + /// a curve, its merged walk's table and its probe twin's), and the largest one. + cursor_bytes: u64, + cursor_largest: u64, } impl GraphFollowRouteControlProducer { @@ -1631,6 +1643,7 @@ pub(crate) fn follow_route_control_pair( dirty: engine::realtime::DirtyWord::new(), }); let automated = source.flags().automated; + let (cursor_bytes, cursor_largest) = runtime::follow_route_cursor_bytes(&source); ( GraphFollowRouteControlBinding { node: GraphNodeId::Route { @@ -1653,6 +1666,8 @@ pub(crate) fn follow_route_control_pair( activity_offset_bytes: runtime::route_activity_offset_bytes(activity_routes) .unwrap_or(u64::MAX), automated, + cursor_bytes, + cursor_largest, }, ) } @@ -1693,7 +1708,9 @@ pub struct RouteControlResources { 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; for a following route the - /// boxed [`FollowRouteControlLane`], its [`GraphFollowRouteControlBinding`] and its owner. + /// boxed [`FollowRouteControlLane`], its [`GraphFollowRouteControlBinding`], its owner and + /// its route op's cursor tables (#1654 T3: per column with a curve, two tables of one cursor + /// per curve). pub owner_bytes: u64, /// The producer tables: one [`GraphRouteControlProducer`] per six-word route and one /// [`GraphFollowRouteControlProducer`] per following route. @@ -1761,6 +1778,10 @@ pub fn route_control_resources( 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); + // #1654 T3: each following route's cursor tables, sized by its own curves. + let cursor_bytes = following.iter().fold(0_u64, |sum, producer| { + sum.saturating_add(producer.cursor_bytes) + }); let owner_bytes = six .saturating_mul(lane.saturating_add(binding).saturating_add(owner)) .saturating_add( @@ -1769,7 +1790,11 @@ pub fn route_control_resources( .saturating_add(follow_binding) .saturating_add(follow_owner), ), - ); + ) + .saturating_add(cursor_bytes); + for producer in following { + largest = largest.max(producer.cursor_largest); + } let six_producers = six.saturating_mul(count(core::mem::size_of::())); let seven_producers = seven.saturating_mul(count(core::mem::size_of::< @@ -11009,11 +11034,14 @@ mod follow_route_tests { + core::mem::size_of::() + LIVE_ROUTE_OWNER_BYTES, ); + // #1654 T3: the one-lane source's left column hears one curve: its walk's table and its + // probe twin's, one cursor each. + let cursors = 2 * size(automation::Composed::<([f32; 2], bool)>::bytes(1)); let seven_owner = size( core::mem::size_of::() + core::mem::size_of::() + LIVE_FOLLOW_ROUTE_OWNER_BYTES, - ); + ) + cursors; 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); diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index 9a03531b3..7f7036009 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -1273,17 +1273,19 @@ impl LiveRoute { // REALTIME_POLICY_END -/// 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. +/// One following route's render state (#1652 D1, D2; #1654 T3): a route into a submix whose +/// source strip has an automated mute lane, or whose own `gain_db`, `mute` or matrix rows are +/// automated. Its own type, boxed beside [`LiveRoute`], so a six-word route keeps its bytes +/// ([`live_route_owner_bytes`]), its code path and its bits. /// -/// `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). +/// `transform` and `flags` are the last applied cell words (or the seeded ones): render composes +/// each source column's target itself ([`column_target`]: the route's gain law, its matrix and +/// its gate, [`gated_route_coefficients`]) at the block entry after its cell changes and at every +/// event of the curves it hears, which it walks with one merged walk per column +/// ([`automation::Composed`], K1) in the node time of its tap, `τ_tap(r) = timeline(r + ΣP - +/// a(tap))`. Only a column whose target bits change retargets: a new [`IndexedRamp`] for that +/// column from the coefficients it is at ([`ColumnRamps`], root ruling R18-5), over the event's +/// own ramp. pub(crate) struct LiveFollowRoute { /// The two column ramps and how far into each the route is. ramps: ColumnRamps, @@ -1293,15 +1295,17 @@ pub(crate) struct LiveFollowRoute { delayed: bool, /// The route's index in the plan's [`RouteActivity`]. route: u32, - /// The open transform the last applied record (or preparation) gave. + /// The open transform the last applied record (or preparation) gave. When the plan automates + /// the route's gain, `gain` is the hand offset in dB; an automated matrix entry's word is the + /// static fallback, which render does not hear. transform: crate::RouteTransform, /// The flags the last applied record (or preparation) gave; render composes with the plan's - /// `automated` ([`FollowCursors::automated`]), never the record's. + /// automated set (the cursors it holds, [`ColumnRoles`]), never the record's. flags: crate::RouteFollowFlags, control: Box, - /// The cursors over the source lanes' stored mute cells. + /// The merged walks over the curves the route hears. follow: FollowCursors, - /// This block's node spans and jump length, set by [`Self::begin_block`] and read by + /// This block's node spans and jump lengths, set by [`Self::begin_block`] and read by /// [`Self::mix`]. block: FollowBlock, } @@ -1367,33 +1371,46 @@ impl ColumnRamps { 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]; - } + /// Retarget column `column` when its entries of `target` change bits, from the coefficients + /// it is at, over its own `length` (#1654 T3: each column takes its event's ramp); a column + /// whose bits do not change keeps its ramp and position. The new ramp holds the target in the + /// other column's entries, which are never read, so only its own entries decide whether it + /// ramps or steps ([`IndexedRamp::new`]'s non-finite rule). + fn retarget_column(&mut self, column: usize, target: [f32; 4], length: u32) { + let (Some(indices), Some(ramp), Some(&position)) = ( + COLUMNS.get(column), + self.ramps.get(column), + self.positions.get(column), + ) else { + return; + }; + if indices + .iter() + .all(|&index| target[index].to_bits() == ramp.target[index].to_bits()) + { + return; + } + let at = ramp.coefficients_at(position); + let mut start = target; + for &index in indices { + start[index] = at[index]; + } + if let (Some(ramp), Some(position)) = + (self.ramps.get_mut(column), self.positions.get_mut(column)) + { *ramp = IndexedRamp::new(start, target, length); *position = 0; } } + /// Column `column`'s remaining ramp in samples (`0` when settled). + fn remaining(&self, column: usize) -> u32 { + match (self.ramps.get(column), self.positions.get(column)) { + (Some(ramp), Some(&position)) => ramp.length.saturating_sub(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]) { @@ -1436,20 +1453,167 @@ impl ColumnRamps { } // 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 cursor rows' jump-length rule. +/// The roles of the curves one source column hears (#1654 T3): its source lane's stored mute, +/// the route's own mute, the route's gain and the column's two matrix entries, in that order. +const ROLES: usize = 5; +const SOURCE_MUTE: usize = 0; +const ROUTE_MUTE: usize = 1; +const GAIN: usize = 2; +/// The column's matrix entries, in the order of its [`COLUMNS`] indices. +const MATRIX: [usize; 2] = [3, 4]; + +/// One source column's composed target: its two coefficients (its [`COLUMNS`] entries) and +/// whether its gate silences it. +type ColumnTarget = ([f32; 2], bool); + +/// Which cursor of a column's table carries each role, by role; `None` for a role the plan does +/// not automate. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct ColumnRoles([Option; ROLES]); + +impl ColumnRoles { + /// The cursor index of `role`. + fn of(self, role: usize) -> Option { + self.0.get(role).copied().flatten() + } + + /// The jump-length row of `role`: the mute rule for both mutes, `SendGain` for the gain, + /// `SendMatrix` for a matrix entry. + const fn row(role: usize) -> crate::GraphRouteCursorRow { + match role { + SOURCE_MUTE => crate::GraphRouteCursorRow::SourceMute, + ROUTE_MUTE => crate::GraphRouteCursorRow::Mute, + GAIN => crate::GraphRouteCursorRow::Gain, + _ => crate::GraphRouteCursorRow::Matrix, + } + } + + /// The route row (`GRAPH_ROUTE_ROWS` order: `gain_db`, `mute`, `ll`, `lr`, `rl`, `rr`) of + /// `role` in `column`; `None` for the source mute, which is not a route row. + fn route_row(column: usize, role: usize) -> Option { + let indices = COLUMNS.get(column)?; + match role { + ROUTE_MUTE => Some(1), + GAIN => Some(0), + _ => { + let entry = MATRIX.iter().position(|&matrix| matrix == role)?; + indices.get(entry).map(|&index| index + 2) + } + } + } +} + +/// How a carried cursor is placed at its route's first block (#1654 D3, T3). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Placement { + /// The predecessor held the same table: its state and heard value carry verbatim. + Continued, + /// The predecessor held another table of the row: its state carries and is repositioned at + /// the successor's node time, so its target is still the one the column heard. + Reposition, + /// The predecessor did not automate the row: a state with no target, placed at the + /// successor's node time. + New, +} + +/// The cells of `column`'s cursor table under `source`, their count and their roles. Control side. +fn column_cells( + source: &crate::GraphFollowRouteSource, + column: usize, +) -> ([(u32, automation::CursorKind); ROLES], usize, ColumnRoles) { + use automation::CursorKind::{Gate, Value}; + let indices = COLUMNS.get(column).copied().unwrap_or([0, 2]); + let route_cell = |row: usize| source.route_cells.get(row).copied().flatten(); + let wanted = [ + ( + SOURCE_MUTE, + source.cells.get(column).copied().flatten(), + Gate, + ), + (ROUTE_MUTE, route_cell(1), Gate), + (GAIN, route_cell(0), Value), + (MATRIX[0], route_cell(2 + indices[0]), Value), + (MATRIX[1], route_cell(2 + indices[1]), Value), + ]; + let mut cells = [(0, Gate); ROLES]; + let mut roles = [None; ROLES]; + let mut count = 0; + for (role, cell, kind) in wanted { + let Some(cell) = cell else { + continue; + }; + if let (Some(slot), Some(role_slot)) = (cells.get_mut(count), roles.get_mut(role)) { + *slot = (cell, kind); + *role_slot = Some(count); + count += 1; + } + } + (cells, count, ColumnRoles(roles)) +} + +/// The cursor tables bind allocates for one following route of `source` (#1654 T3, R19-3): per +/// column with a curve, its walk's and its probe twin's tables. Returns the total bytes and the +/// largest single table. +pub(crate) fn follow_route_cursor_bytes(source: &crate::GraphFollowRouteSource) -> (u64, u64) { + let mut total = 0_u64; + let mut largest = 0_u64; + for column in 0..COLUMNS.len() { + let (_, count, _) = column_cells(source, column); + if count == 0 { + continue; + } + let table = + u64::try_from(automation::Composed::::bytes(count)).unwrap_or(u64::MAX); + total = total.saturating_add(table.saturating_mul(2)); + largest = largest.max(table); + } + (total, largest) +} + +/// One source column's merged walk (#1654 T3, K1): its cursor table over the curves it hears, a +/// twin over the same cells for the block entry's activity probe, its roles and its cursors' +/// placement for a carried first block. +struct RouteColumn { + walk: automation::Composed, + probe: automation::Composed, + roles: ColumnRoles, + placement: [Placement; ROLES], +} + +impl RouteColumn { + /// `column`'s walk under `source`, or `None` when the column hears no curve. Control side: + /// it allocates the two tables ([`follow_route_cursor_bytes`]). + fn bind(source: &crate::GraphFollowRouteSource, column: usize) -> Option { + let (cells, count, roles) = column_cells(source, column); + let cells = cells.get(..count).filter(|cells| !cells.is_empty())?; + let table = || { + // The source holds only cells of its program (`GraphFollowRouteSource::new`), so the + // one refusal left is an allocation failure, which bind treats as every other. + automation::Composed::new(&source.program, cells, automation::Completion::End) + .expect("a following route's cursor table allocates") + }; + Some(Self { + walk: table(), + probe: table(), + roles, + placement: [Placement::Continued; ROLES], + }) + } +} + +/// A following route's merged walks over the curves it hears (#1652 D2, #1654 T3): the plan's +/// immutable program, one [`RouteColumn`] per source column that hears a curve, whether each +/// column's target silences it, `a(tap)`, the cursor rows' jump-length rule, the route's gain law +/// and which route rows continue the predecessor's table at a carry. 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; on a - /// carried route, the predecessor's until its first block places the cursors. - curve: [bool; 2], + columns: [Option; 2], + silent: [bool; 2], /// `a(tap)`. arrival: u64, jumps: crate::GraphRouteJumpRule, + gain_law: crate::GraphRouteGainLaw, + continued: [bool; crate::GRAPH_ROUTE_ROWS], /// How the route's next block begins: fresh, carried or running. start: FollowStart, } @@ -1457,70 +1621,91 @@ struct FollowCursors { /// 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). + /// The route of a fresh plan has rendered no block: its first block sets a column exactly at + /// its first sample when a curve there differs from its seed (#1647 D2, as the strip's fresh + /// lane). Fresh, - /// 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. + /// The route took a predecessor's ramps and cursors at a plan swap (D3): its first block + /// applies its cell with the heard values, then places the cursors that do not continue at + /// its own node time and compares. Carried, /// Every later block. Running, } -/// 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. +/// One block's node span per column, its cursor rows' jump lengths and the node time at its first +/// sample (#1652b, #1654 T3). A column with no span holds its curves and emits nothing this block. #[derive(Clone, Copy)] struct FollowBlock { spans: [Option; 2], - jump: u32, + lengths: crate::GraphRouteJumpLengths, + start: Option, } impl FollowBlock { const IDLE: Self = Self { spans: [None; 2], - jump: 0, + lengths: crate::GraphRouteJumpLengths { + mute: 0, + gain: 0, + matrix: 0, + }, + start: None, }; } +/// What a column's composition reads besides its heard values: the open transform, the flags and +/// the gain law. +#[derive(Clone, Copy)] +struct Composition<'a> { + transform: &'a crate::RouteTransform, + flags: crate::RouteFollowFlags, + law: crate::GraphRouteGainLaw, +} + 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; both column ramps are settled on it, so an idle - /// route mixes exactly what a prepared route of that gate mixes. Control side. + /// The bound route (#1652b, #1654 T3): its open transform `transform` (the plan's prepared + /// route), its cell's reader `control` and its source `source`. Each column's cursors are + /// seeded with their curves' first `start_value`, and each column's bound target composes + /// them with the seeded 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 target mixes. + /// Control side: it allocates the cursor tables. 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 { + let mut follow = FollowCursors { program: std::sync::Arc::clone(&source.program), - cells: source.cells, - states, - curve, + columns: [0, 1].map(|column| RouteColumn::bind(source, column)), + silent: [false; 2], arrival: source.arrival, jumps: source.jumps, + gain_law: source.gain_law, + continued: source.continued, start: FollowStart::Fresh, }; - let (target, mute) = follow.compose(&transform, source.flags, curve); + let composition = Composition { + transform: &transform, + flags: source.flags, + law: source.gain_law, + }; + let targets = [0, 1].map(|column| follow.heard_target(column, composition)); + for ((column, target), silent) in follow + .columns + .iter_mut() + .zip(targets) + .zip(&mut follow.silent) + { + *silent = target.1; + if let Some(column) = column { + column.walk.seed_target(target); + column.probe.seed_target(target); + } + } Self { - ramps: ColumnRamps::settled(target), - mute, + ramps: ColumnRamps::settled(full_target(targets)), + mute: targets[0].1 && targets[1].1, delayed: false, route: u32::MAX, transform, @@ -1533,59 +1718,169 @@ impl LiveFollowRoute { } // 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()] +/// The full four-coefficient target of two column targets. +const fn full_target(targets: [ColumnTarget; 2]) -> [f32; 4] { + let [(zero, _), (one, _)] = targets; + [zero[0], one[0], zero[1], one[1]] +} + +/// `full` with `column`'s entries replaced by `entries`. +fn with_column(mut full: [f32; 4], column: usize, entries: [f32; 2]) -> [f32; 4] { + if let Some(indices) = COLUMNS.get(column) { + for (&index, entry) in indices.iter().zip(entries) { + if let Some(slot) = full.get_mut(index) { + *slot = entry; + } + } + } + full +} + +/// `column`'s entries of `full`. +fn column_entries(full: [f32; 4], column: usize) -> [f32; 2] { + let indices = COLUMNS.get(column).copied().unwrap_or([0, 2]); + indices.map(|index| full.get(index).copied().unwrap_or(0.0)) +} + +/// Whether `a` and `b` have the same bits. +fn same_entries(a: [f32; 2], b: [f32; 2]) -> bool { + a.map(f32::to_bits) == b.map(f32::to_bits) +} + +/// The open transform's words, for a value-change test. +const fn transform_bits(transform: &crate::RouteTransform) -> [u32; 5] { + [ + transform.gain.to_bits(), + transform.ll.to_bits(), + transform.lr.to_bits(), + transform.rl.to_bits(), + transform.rr.to_bits(), + ] +} + +/// Source column `column`'s target (#1652 D2, #1654 D2, D5): the open transform with the plan's +/// automated values from the curves the column hears (`value(cursor)`): the gain through the +/// route's gain law with the record's hand offset (`law(curve, offset)`), each automated matrix +/// entry of the column from its curve; gated by `mute = static mute || route mute curve` (D5, +/// an OR) and the column's follow term `follows_mute && (source curve || terms)`; +/// [`gated_route_coefficients`] of that, the column's entries. The one composition of the seed, +/// the cell's block entry, a record's target and every curve event, so their bits cannot differ. +/// A column's entries do not depend on the other column's gate, so the two columns compose apart. +fn column_target( + column: usize, + roles: ColumnRoles, + composition: Composition<'_>, + value: impl Fn(usize) -> f32, +) -> ColumnTarget { + let Composition { + transform, + flags, + law, + } = composition; + let heard = |role: usize| roles.of(role).map(&value); + let mut open = *transform; + if let Some(curve) = heard(GAIN) { + open.gain = law(curve, transform.gain); + } + let indices = COLUMNS.get(column).copied().unwrap_or([0, 2]); + for (&index, &role) in indices.iter().zip(&MATRIX) { + if let Some(curve) = heard(role) { + match index { + 0 => open.ll = curve, + 1 => open.lr = curve, + 2 => open.rl = curve, + _ => open.rr = curve, + } + } } + let mute = flags.mute || heard(ROUTE_MUTE).is_some_and(|curve| curve != 0.0); + let zeroed = flags.follows_mute + && (heard(SOURCE_MUTE).is_some_and(|curve| curve != 0.0) + || flags.terms.get(column).copied().unwrap_or(false)); + let mut follow_zeroed = [false; 2]; + if let Some(slot) = follow_zeroed.get_mut(column) { + *slot = zeroed; + } + let all = gated_route_coefficients( + &open, + crate::RouteGate { + mute, + follow_zeroed, + }, + ); + (column_entries(all, column), mute || zeroed) +} - /// [`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) +impl FollowCursors { + /// Column `column`'s target composed with the values it has heard (a column with no curve: + /// the cell's words alone). + fn heard_target(&self, column: usize, composition: Composition<'_>) -> ColumnTarget { + match self.columns.get(column).and_then(Option::as_ref) { + Some(walk) => walk.walk.compose_heard(|heard| { + column_target(column, walk.roles, composition, |index| heard.value(index)) + }), + None => column_target(column, ColumnRoles::default(), composition, |_| 0.0), + } } - /// 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`). + /// Per column, whether the source curve the route has heard zeroes it under `flags`: the + /// curve's share of the follow term (`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 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]) { + [0, 1].map(|column| { + flags.follows_mute + && self + .columns + .get(column) + .and_then(Option::as_ref) + .and_then(|walk| { + let index = walk.roles.of(SOURCE_MUTE)?; + walk.walk + .cursors() + .get(index) + .map(|cursor| cursor.heard() != 0.0) + }) + .unwrap_or(false) + }) + } + + /// The block's spans at the tap's node time (`node_span(a(tap))`, R17-3), one per column, + /// and its cursor rows' jump lengths (the host-core rule over the block's lengths, each at + /// most the route ramp bound), which every cursor takes. Takes the start and returns it: the + /// first block of a fresh route with no seek reaching it lands a `Set` at offset 0 in a + /// column one of whose curves there differs from what its cursor holds. With no jump lengths + /// or no node span the block has no events and the curves hold (#1652 NIT-1: production + /// plans always attach both). + fn block(&mut self, time: &crate::GraphBlockTime<'_>) -> (FollowBlock, FollowStart) { 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, start, placed); + return (FollowBlock::IDLE, start); }; - let jump = core::cmp::min( - (self.jumps)(lengths).for_row(crate::GraphRouteCursorRow::SourceMute), - crate::ROUTE_RAMP_LENGTH_MAXIMUM, - ); + let rule = (self.jumps)(lengths); + let bound = |length: u32| core::cmp::min(length, crate::ROUTE_RAMP_LENGTH_MAXIMUM); + let lengths = crate::GraphRouteJumpLengths { + mute: bound(rule.mute), + gain: bound(rule.gain), + matrix: bound(rule.matrix), + }; + for column in self.columns.iter_mut().flatten() { + for role in 0..ROLES { + if let Some(cursor) = column + .roles + .of(role) + .and_then(|index| column.walk.cursor_mut(index)) + { + cursor.set_jump(lengths.for_row(ColumnRoles::row(role))); + } + } + } let idle = FollowBlock { spans: [None; 2], - jump, + lengths, + start: None, }; let Some(span) = time.node_span(self.arrival) else { - return (idle, start, placed); + return (idle, start); }; let length = i64::from(frames); if frames == 0 @@ -1594,23 +1889,11 @@ impl FollowCursors { .step .is_some_and(|step| step.timeline.checked_add(length).is_none()) { - return (idle, start, placed); + return (idle, start); } - let mut spans = [None; 2]; - for (((slot, cell), state), placed) in spans - .iter_mut() - .zip(self.cells) - .zip(&mut self.states) - .zip(&mut placed) - { - 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); - *placed = Some(value != 0.0); - } + let program = &self.program; + let spans = [0, 1].map(|column| { + let walk = self.columns.get(column).and_then(Option::as_ref)?; let step = span .step .map(|step| (step.offset, step.timeline)) @@ -1618,96 +1901,158 @@ impl FollowCursors { if start != FollowStart::Fresh { return None; } - let value = automation::value_at(program.segments(), span.start); - (state.target().map(f32::to_bits) != Some(value.to_bits())) - .then_some((0, span.start)) + let differs = walk.walk.cursors().iter().any(|cursor| { + program.cell(cursor.cell() as usize).is_some_and(|cell| { + let value = automation::value_at(cell.segments(), span.start); + cursor.state().target().map(f32::to_bits) != Some(value.to_bits()) + }) + }); + differs.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 }, start, placed) + // the column holding its curves, with no panic. + automation::NodeSpan::new(span.start, step, frames).ok() + }); + ( + FollowBlock { + spans, + lengths, + start: Some(span.start), + }, + start, + ) } } -/// 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( +/// Walk one block's events of both columns in offset order (#1652 D2, #1654 T3), each column's +/// merged walk ([`automation::Composed`]) composing with [`column_target`], and call +/// `change(column, event)` per event, a column's events in its own order and the two columns' +/// at one offset in column order. Bounded by the columns' per-block event bounds +/// ([`automation::Composed::max_events`]); allocation-, lock- and panic-free. +fn walk_columns( 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), + mut columns: [Option<(&mut automation::Composed, ColumnRoles)>; 2], + spans: [Option; 2], + composition: Composition<'_>, + mut change: impl FnMut(usize, automation::ComposedEvent), ) { - 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), - ]; + for (column, span) in columns.iter_mut().zip(spans) { + match (column.as_mut(), span) { + (Some((walk, _)), Some(span)) => { + walk.start(program, span, automation::SeekMode::Exact); + } + _ => *column = None, + } + } let mut next = [None; 2]; - for (next, events) in next.iter_mut().zip(&mut events) { - *next = events.as_mut().and_then(Iterator::next); + for (index, (slot, column)) in next.iter_mut().zip(&mut columns).enumerate() { + *slot = next_column_event(index, column, program, composition); } - // Each pass consumes at least one event, so the loop ends with the iterators. + // Each pass consumes at least one event, so the loop ends with the walks. 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 { + for (index, (slot, column)) in next.iter_mut().zip(&mut columns).enumerate() { + let Some(event) = slot.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(index, event); + *slot = next_column_event(index, column, program, composition); + } + } +} + +/// Column `index`'s next event, if it walks this block. +fn next_column_event( + index: usize, + column: &mut Option<(&mut automation::Composed, ColumnRoles)>, + program: &automation::Program, + composition: Composition<'_>, +) -> Option> { + let (walk, roles) = column.as_mut()?; + let roles = *roles; + walk.next_event(program, |heard| { + column_target(index, roles, composition, |cursor| heard.value(cursor)) + }) +} + +/// Call `each` on every value cursor of `walk` that a carried first block placed (it does not +/// continue its predecessor's table). +fn for_placed_values(walk: &mut RouteColumn, mut each: impl FnMut(&mut automation::ComposeCursor)) { + for role in 0..ROLES { + let placed = walk + .placement + .get(role) + .is_some_and(|&placement| placement != Placement::Continued); + if let (Some(index), true) = (walk.roles.of(role), placed) + && let Some(cursor) = walk.walk.cursor_mut(index) + && cursor.kind() == automation::CursorKind::Value + { + each(cursor); } - change(offset, *curve, set); } } impl LiveFollowRoute { /// 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`. + /// coefficients it is at over the record's ramp (#1652 D2). Returns how many values were + /// applied: `0` or `1`. + /// + /// With `at`, the node time of the block's first sample (a block entry), a record that + /// changes a value word (the hand offset of an automated gain, or a value the plan does not + /// automate, D6) takes each automated value from its curve at the record ramp's completion + /// (#1649 D3 on the route), and a gate-only record composes with the values heard (#1652); a + /// column the record moves is a column jump (R19-3): its value cursors hold their grid until + /// the ramp's end and land over their own length if their curve moved. With no `at` (a + /// predecessor's last read at a swap) the record composes with the values heard. #[inline] - fn apply_cell(&mut self, snapshot: LiveSnapshot) -> usize { + fn apply_cell(&mut self, snapshot: LiveSnapshot, at: Option) -> usize { let mut applied = 0; if let Some(record) = self.control.read(snapshot) { + let values = transform_bits(&record.transform()) != transform_bits(&self.transform); self.transform = record.transform(); self.flags = record.flags(); - let (target, mute) = - self.follow - .compose(&self.transform, self.flags, self.follow.curve); - self.mute = mute; - self.ramps.retarget(target, record.length()); + let length = record.length(); + let Self { + ramps, + mute, + transform, + flags, + follow, + .. + } = self; + let composition = Composition { + transform, + flags: *flags, + law: follow.gain_law, + }; + for column in 0..COLUMNS.len() { + let target = match (follow.columns.get(column).and_then(Option::as_ref), at) { + (Some(walk), Some(start)) if values => { + walk.walk + .compose_toward(&follow.program, start, length, |heard| { + column_target(column, walk.roles, composition, |index| { + heard.value(index) + }) + }) + } + _ => follow.heard_target(column, composition), + }; + let moves = !same_entries(column_entries(ramps.target(), column), target.0); + ramps.retarget_column( + column, + with_column(ramps.target(), column, target.0), + length, + ); + if let Some(silent) = follow.silent.get_mut(column) { + *silent = target.1; + } + if let Some(walk) = follow.columns.get_mut(column).and_then(Option::as_mut) { + match at { + Some(start) if moves => walk.walk.retarget(target, start, length), + _ => walk.walk.seed_target(target), + } + } + } + *mute = follow.silent[0] && follow.silent[1]; applied = 1; } #[cfg(any(test, feature = "test-support"))] @@ -1716,109 +2061,253 @@ impl LiveFollowRoute { } /// Take the ramp state of the predecessor's live route of the same ID, of either kind - /// (#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]) { + /// (#1652 D3, #1654 D3): its column ramps (a six-word predecessor's one ramp in both + /// columns), their positions and its mute; from a following predecessor `from`, per column and + /// role, each cursor's state and heard value. A route row whose table continues + /// ([`crate::GraphFollowRouteSource::with_continued_rows`]) keeps them verbatim; every other + /// cursor is placed at this plan's first block (a cursor the predecessor did not hold starts + /// with no target), after its cell applies with the heard values, and the block entry then + /// compares. + fn adopt(&mut self, ramps: ColumnRamps, mute: bool, from: Option<&Self>) { self.ramps = ramps; self.mute = mute; - self.follow.curve = curve; + self.follow.silent = from.map_or([mute; 2], |from| from.follow.silent); + let continued = self.follow.continued; + for (column, walk) in self.follow.columns.iter_mut().enumerate() { + let Some(walk) = walk else { + continue; + }; + let previous = from + .and_then(|from| from.follow.columns.get(column)) + .and_then(Option::as_ref); + for role in 0..ROLES { + let Some(index) = walk.roles.of(role) else { + continue; + }; + let carried = previous.and_then(|previous| { + let index = previous.roles.of(role)?; + previous.walk.cursors().get(index) + }); + let continues = ColumnRoles::route_row(column, role) + .and_then(|row| continued.get(row).copied()) + .unwrap_or(false); + let placement = match carried { + Some(_) if continues => Placement::Continued, + Some(_) => Placement::Reposition, + None => Placement::New, + }; + if let Some(cursor) = walk.walk.cursor_mut(index) { + match carried { + Some(carried) => { + *cursor.state_mut() = *carried.state(); + cursor.set_heard(carried.heard()); + } + None => { + *cursor.state_mut() = automation::CellState::new(); + if cursor.kind() == automation::CursorKind::Gate { + // A curve the predecessor did not hear gated nothing. + cursor.set_heard(0.0); + } + } + } + } + if let Some(slot) = walk.placement.get_mut(role) { + *slot = placement; + } + } + } 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 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. + /// A carried route's first block, after its cell (#1652 D3, #1654 D3, R19-5), at node time + /// `at`: each cursor that does not continue is placed there, a gate with the curve's value (no + /// `Set`), a value with its curve's value heard and its state repositioned (its target still + /// the one the column heard). Then each column composes and compares with the adopted ramp's + /// target: equal bits leave the ramp (the placed values become the cursors' targets); a gate + /// that moved retargets the column at once over the mute length, a column jump (R19-3); a + /// value that moved alone defers the jump of the column's placed value cursors to the column + /// ramp's first free sample, at once when it is settled (#1711 D1), where each one whose curve + /// moved jumps over its own row's length. A column with no curve compares its cell's target + /// over the mute length, as before. + fn place_carried(&mut self, at: i64) { + let Self { + ramps, + mute, + transform, + flags, + follow, + block, + .. + } = self; + let composition = Composition { + transform, + flags: *flags, + law: follow.gain_law, + }; + let jump = block.lengths.mute; + for column in 0..COLUMNS.len() { + let adopted = column_entries(ramps.target(), column); + let Some(walk) = follow.columns.get_mut(column).and_then(Option::as_mut) else { + let target = column_target(column, ColumnRoles::default(), composition, |_| 0.0); + ramps.retarget_column(column, with_column(ramps.target(), column, target.0), jump); + if let Some(silent) = follow.silent.get_mut(column) { + *silent = target.1; + } + continue; + }; + let mut gate_moved = false; + for role in 0..ROLES { + let placed = walk + .placement + .get(role) + .is_some_and(|&p| p != Placement::Continued); + let (Some(index), true) = (walk.roles.of(role), placed) else { + continue; + }; + let Some(cursor) = walk.walk.cursor_mut(index) else { + continue; + }; + let Some(cell) = follow.program.cell(cursor.cell() as usize) else { + continue; + }; + let value = automation::value_at(cell.segments(), at); + cursor.state_mut().reposition(&cell, at); + if cursor.kind() == automation::CursorKind::Gate { + gate_moved |= (cursor.heard() != 0.0) != (value != 0.0); + cursor.state_mut().seed_target(value); + } + cursor.set_heard(value); + } + let roles = walk.roles; + let target = walk.walk.compose_heard(|heard| { + column_target(column, roles, composition, |index| heard.value(index)) + }); + if same_entries(adopted, target.0) { + for_placed_values(walk, |cursor| { + let heard = cursor.heard(); + cursor.state_mut().seed_target(heard); + }); + walk.walk.seed_target(target); + if let Some(silent) = follow.silent.get_mut(column) { + *silent = target.1; + } + } else if gate_moved { + ramps.retarget_column(column, with_column(ramps.target(), column, target.0), jump); + walk.walk.retarget(target, at, jump); + if let Some(silent) = follow.silent.get_mut(column) { + *silent = target.1; + } + } else { + let silent = follow.silent.get(column).copied().unwrap_or(false); + walk.walk.seed_target((adopted, silent)); + let free = at.saturating_add(i64::from(ramps.remaining(column))); + for_placed_values(walk, |cursor| cursor.state_mut().defer_jump(free)); + } + } + *mute = follow.silent[0] && follow.silent[1]; + } + + /// The route op's block entry (#1652 D2, #1654 T3): take the block's spans at the tap's node + /// time; read 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 cursors and + /// compare; 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; 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]`). + /// The cell composes with the values the route has heard, 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 each column's probe twin, 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 twins' state (no event of it retargets, since every silencing target is `[+0.0; 4]`). #[inline] 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); + let (block, start) = self.follow.block(time); self.block = block; - self.apply_cell(snapshot); - 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; - self.ramps.retarget(target, self.block.jump); + self.apply_cell(snapshot, block.start); + if start == FollowStart::Carried + && let Some(at) = block.start + { + self.place_carried(at); } 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.ramps.target().map(f32::to_bits); + let Self { + ramps, + transform, + flags, + follow, + block, + .. + } = self; + let FollowCursors { + program, + columns, + silent: heard_silent, + gain_law, + .. + } = follow; + let composition = Composition { + transform, + flags: *flags, + law: *gain_law, + }; + for column in columns.iter_mut().flatten() { + let _copied = column.probe.copy_from(&column.walk); + } + let mut target = ramps.target().map(f32::to_bits); + let mut silent = *heard_silent; let mut from = None; - 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, - |offset, curve, set| { - let (next, mute) = follow.compose(transform, flags, curve); - let next = next.map(f32::to_bits); + let [zero, one] = columns; + walk_columns( + program, + [ + zero.as_mut().map(|walk| (&mut walk.probe, walk.roles)), + one.as_mut().map(|walk| (&mut walk.probe, walk.roles)), + ], + block.spans, + composition, + |column, event| { + if event.held { + return; + } + if let Some(slot) = silent.get_mut(column) { + *slot = event.value.1; + } + let muted = silent[0] && silent[1]; + let next = with_column(target.map(f32::from_bits), column, event.value.0) + .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; + let settled = + next == target || event.kind == automation::EventKind::Set || event.ramp == 0; target = next; - if from.is_none() && !(mute && settled) { - from = Some(offset); + if from.is_none() && !(muted && settled) { + from = Some(event.offset); } }, ); if from.is_none() { - self.follow.states = states; - self.follow.curve = curve; + for column in [zero, one].into_iter().flatten() { + let _copied = column.walk.copy_from(&column.probe); + } + *heard_silent = silent; } from } - /// D2: mix the block, retargeting at each curve event whose target's bits change: mix - /// `[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. + /// D2: mix the block, retargeting at each column event whose target's bits change: mix + /// `[0, o)`, retarget that column from the coefficients it is at over the event's ramp (`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 { ramps, mute, @@ -1830,27 +2319,43 @@ impl LiveFollowRoute { } = self; let FollowCursors { program, - cells, - states, - curve, + columns, + silent, + gain_law, .. } = follow; - let jump = block.jump; - follow_walk( + let composition = Composition { + transform, + flags: *flags, + law: *gain_law, + }; + let [zero, one] = columns; + walk_columns( 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) == ramps.target().map(f32::to_bits) { + [ + zero.as_mut().map(|walk| (&mut walk.walk, walk.roles)), + one.as_mut().map(|walk| (&mut walk.walk, walk.roles)), + ], + block.spans, + composition, + |column, event| { + if event.held { return; } - let at = core::cmp::min(offset as usize, frames); + if let Some(slot) = silent.get_mut(column) { + *slot = event.value.1; + } + *mute = silent[0] && silent[1]; + if same_entries(column_entries(ramps.target(), column), event.value.0) { + return; + } + let at = core::cmp::min(event.offset as usize, frames); mix_follow_part(left, right, from, at, ramps); - ramps.retarget(target, if set { 0 } else { jump }); + ramps.retarget_column( + column, + with_column(ramps.target(), column, event.value.0), + event.ramp, + ); from = at; }, ); @@ -5758,9 +6263,9 @@ fn finish_six_word_carry( /// following route) the curve the predecessor had heard. /// /// * six -> six: [`LiveRoute::adopt`], exactly as before #1652. -/// * 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`]). +/// * either -> seven: [`LiveFollowRoute::adopt`] (a following predecessor's cursors by column and +/// role, #1654 D3); its first block applies its cell with the heard values, places the cursors +/// that do not continue 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 @@ -5787,18 +6292,14 @@ fn carry_route( ) } CarriedRoute::Seven(from) => { - from.apply_cell(LiveSnapshot::ALL); - let zeroed = from.follow.heard_zeroed(from.flags); - ( - from.ramps, - from.mute, - Some((from.follow.curve, zeroed, from.follow.jumps)), - ) + from.apply_cell(LiveSnapshot::ALL, None); + let from: &LiveFollowRoute = from; + (from.ramps, from.mute, Some(from)) } }; match into { CarriedRoute::Seven(into) => { - into.adopt(ramps, mute, follow.map_or([false; 2], |(curve, ..)| curve)); + into.adopt(ramps, mute, follow); false } CarriedRoute::Six(into) => { @@ -5807,9 +6308,10 @@ fn carry_route( let (prepared, prepared_mute) = (into.ramp.target, into.mute); let (ramp, position) = ramps.one_ramp(); into.adopt_state(ramp, position, mute); - let Some((_, zeroed, jumps)) = follow else { + let Some(from) = follow else { return false; }; + let (zeroed, jumps) = (from.follow.heard_zeroed(from.flags), from.follow.jumps); if prepared.map(f32::to_bits) == ramp.target.map(f32::to_bits) && zeroed == [false; 2] { into.mute = prepared_mute; return false; @@ -18861,3 +19363,697 @@ mod follow_route_op_tests { } } } + +#[cfg(test)] +#[allow( + clippy::needless_range_loop, + reason = "block and column indices name node times and columns in the assertions" +)] +mod send_automation_tests { + //! #1654 T3: the following route op over a send's own stored rows (gain, mute, matrix) on the + //! merged walk (K1), driven at its block entry and its mix from graph fixtures. The route op + //! is not banked and mixes at `FrameLane` on every backend, so no case depends on a bank + //! width. + use super::{ColumnRamps, FrameLane, IndexedRamp, LiveFollowRoute, route_mix_ramp_block}; + use crate::follow_route_tests::route_jumps; + use crate::{ + FollowRouteControlRecord, GRAPH_ROUTE_ROWS, GraphBlockTime, GraphFollowRouteSource, + GraphJumpLengths, GraphPreparedSourceSetDriver, RenderError, RouteFollowFlags, RouteGate, + RouteTransform, StableGraphId, TimelineSpan, gated_route_coefficients, + }; + use automation::{Segment, Shape}; + use engine::realtime::{GatePin, LiveValuesSuperseded, RevisionGate}; + + /// The send's lengths: the mute rule 200, `SendGain` 480, `SendMatrix` 960 + /// ([`route_jumps`] maps them from the fader, mute and pan lengths). + const LENGTHS: GraphJumpLengths = GraphJumpLengths { + fader_samples: 480, + mute_samples: 200, + pan_samples: 960, + }; + const QUANTUM: usize = 64; + + /// An identity timeline (`timeline(s) = s`). + struct Timeline; + + 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: None, + }) + } + } + + /// The route's dB law in these cases: a gain linear in `curve + offset` (graph links no + /// `math`), summed in `f64` and rounded once, as `graph_compiler::route_gain_law` shapes it. + fn law(curve_db: f32, offset_db: f32) -> f32 { + (1.0 + (f64::from(curve_db) + f64::from(offset_db)) / 48.0) as f32 + } + + const fn seg(start: u64, end: u64, v0: f32, v1: f32, shape: Shape) -> Segment { + Segment { + start, + end, + start_value: v0, + end_value: v1, + shape, + } + } + + fn flat(value: f32) -> Vec { + vec![seg(0, 1 << 30, value, value, Shape::Linear)] + } + + /// A mute curve `before` until `at`, then `after`. + fn step(at: u64, before: f32, after: f32) -> Vec { + vec![seg(0, at, before, after, Shape::Step)] + } + + /// The curves a send hears: its source lanes' mutes, its gain, its own mute and its matrix + /// entries `[ll, lr, rl, rr]`. + #[derive(Default)] + struct Curves { + source: [Option>; 2], + gain: Option>, + mute: Option>, + matrix: [Option>; 4], + } + + struct Send { + route: Box, + producer: crate::GraphFollowRouteControlProducer, + gate: RevisionGate, + pin: GatePin, + revision: u64, + } + + impl Send { + /// A route of `transform` over `curves`, at `a(tap) = 0`, following its source. + fn new(curves: &Curves, transform: RouteTransform) -> Self { + Self::continuing(curves, transform, [false; GRAPH_ROUTE_ROWS]) + } + + /// [`Self::new`] whose route rows `continued` continue the predecessor's tables. + fn continuing( + curves: &Curves, + transform: RouteTransform, + continued: [bool; GRAPH_ROUTE_ROWS], + ) -> Self { + let rows: [&Option>; GRAPH_ROUTE_ROWS] = [ + &curves.gain, + &curves.mute, + &curves.matrix[0], + &curves.matrix[1], + &curves.matrix[2], + &curves.matrix[3], + ]; + let all: Vec<&Vec> = curves + .source + .iter() + .chain(rows.iter().copied()) + .flatten() + .collect(); + let descriptions: Vec<_> = all + .iter() + .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_u32; + let mut index = |curve: &Option>| { + curve.as_ref().map(|_| { + next += 1; + next - 1 + }) + }; + let cells = [index(&curves.source[0]), index(&curves.source[1])]; + let route_cells = rows.map(&mut index); + let [gain, mute, ll, lr, rl, rr] = route_cells.map(|cell| cell.is_some()); + let flags = RouteFollowFlags { + follows_mute: true, + automated: cells.map(|cell| cell.is_some()), + automated_gain: gain, + automated_mute: mute, + automated_matrix: [ll, lr, rl, rr], + ..RouteFollowFlags::default() + }; + let source = GraphFollowRouteSource::new( + program, + cells, + route_cells, + 0, + route_jumps, + law, + flags, + ) + .expect("source") + .with_continued_rows(continued); + let (binding, producer) = crate::follow_route_control_pair( + &StableGraphId::parse("send").expect("ID"), + &LiveValuesSuperseded::new(), + source, + 0, + 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 route'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 sample reads node time + /// `first`: its block entry, then its mix when active. + fn block(&mut self, first: u64, left: &mut [f32], right: &mut [f32]) -> Option { + let frames = u32::try_from(left.len()).expect("frames"); + 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); + } + from + } + + /// One block of [`QUANTUM`] samples from `first` over a fixed input; its output bits. + fn run(&mut self, first: u64) -> Vec { + let (mut left, mut right) = input(QUANTUM); + self.block(first, &mut left, &mut right); + bits((&left, &right)) + } + } + + 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() + } + + const OPEN: RouteTransform = RouteTransform { + gain: 0.0, + ll: 0.9, + lr: 0.2, + rl: -0.3, + rr: 0.8, + }; + + /// The open coefficients of `transform` with linear gain `gain`. + fn open(transform: RouteTransform, gain: f32) -> [f32; 4] { + gated_route_coefficients(&RouteTransform { gain, ..transform }, RouteGate::OPEN) + } + + /// Gate 1 (flat equals static, batch 10 amendment): a send whose gain rides flat at -6 dB with + /// a hand offset of `+0.0` or `-0.0`, whose `ll` and `rr` ride flat and whose own mute rides + /// flat open binds and mixes, block after block, the bits of a route with those static values + /// (`law(-6, -0.0)`, the static gain's own law, and the curves' matrix entries; the cell's + /// fallback words are not heard). + /// + /// Red if the route op reads the offset word as a linear gain, hears an automated matrix + /// entry's fallback, converts the curve with other arithmetic than the law, or retargets on a + /// flat ride. + #[test] + fn a_flat_send_ride_mixes_the_static_route_bits() { + let curves = Curves { + gain: Some(flat(-6.0)), + mute: Some(flat(0.0)), + matrix: [Some(flat(0.9)), None, None, Some(flat(0.8))], + ..Curves::default() + }; + let fixed = RouteTransform { + gain: law(-6.0, -0.0), + ..OPEN + }; + let expected_coefficients = gated_route_coefficients(&fixed, RouteGate::OPEN); + for offset in [0.0_f32, -0.0] { + let mut send = Send::new( + &curves, + RouteTransform { + gain: offset, + ll: 0.123, + rr: 0.456, + ..OPEN + }, + ); + assert_eq!( + send.route.ramps, + ColumnRamps::settled(expected_coefficients), + "offset {offset:?}: the bound target" + ); + let settled = IndexedRamp::settled(expected_coefficients); + for index in 0..40_u64 { + let (mut left, mut right) = input(QUANTUM); + route_mix_ramp_block::(&mut left, &mut right, &settled, 0); + assert_eq!( + send.run(index * QUANTUM as u64), + bits((&left, &right)), + "offset {offset:?}, block {index}" + ); + } + assert_eq!( + send.route.ramps, + ColumnRamps::settled(expected_coefficients) + ); + } + } + + /// The route's column ramps after each block of a run from node time 0, by block. + fn ramps_by_block(send: &mut Send, blocks: u64) -> Vec<[IndexedRamp; 2]> { + (0..blocks) + .map(|index| { + send.run(index * QUANTUM as u64); + send.route.ramps.ramps + }) + .collect() + } + + /// A ramp from anything toward `target` over `length` (the start is the test's to check). + fn aims(ramp: &IndexedRamp, target: [f32; 4], length: u32, column: usize) -> bool { + let indices = super::COLUMNS[column]; + ramp.length == length + && indices + .iter() + .all(|&index| ramp.target[index].to_bits() == target[index].to_bits()) + } + + /// R19-3 gate (a): a send's gain riding across an unmute of its own mute (1 -> 0 at 1,000): + /// the unmute retargets both columns at 1,000 over the mute length (200) toward the gain the + /// route heard; no gain grid event inside the fade retargets either column, so the fade ends + /// at exactly 1,200; there the gain lands over its own length (480) on the curve at the + /// landing's last frame (1,679); its grid moves the columns again from the first grid point + /// whose ramp ends after the landing (1,664), over the grid ramp. + /// + /// Red if a grid event cuts the column jump, if the held move is not landed at its end, or if + /// it lands over the mute length instead of the gain's. + #[test] + fn a_gain_ride_across_an_unmute_fade_ends_the_fade_at_its_length_and_lands_at_its_end() { + let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let curves = Curves { + gain: Some(ride.clone()), + mute: Some(step(1_000, 1.0, 0.0)), + ..Curves::default() + }; + let mut send = Send::new(&curves, OPEN); + let curve = |t: i64| automation::value_at(&ride, t); + let by_block = ramps_by_block(&mut send, 30); + let fade = by_block[1_000 / QUANTUM]; + // The unmute aims at the gain the route heard: the grid event at 960, toward 1,023. + let heard = open(OPEN, law(curve(1_023), 0.0)); + for column in 0..2 { + assert!( + aims(&fade[column], heard, 200, column), + "the fade, column {column}" + ); + } + for block in 1_000 / QUANTUM..1_200 / QUANTUM { + assert_eq!(by_block[block], fade, "block {block}: the fade runs whole"); + } + let landing = by_block[1_200 / QUANTUM]; + let landed = open(OPEN, law(curve(1_200 + 479), 0.0)); + for column in 0..2 { + assert!( + aims(&landing[column], landed, 480, column), + "the landing, column {column}" + ); + let ended = fade[column].coefficients_at(200); + assert!( + super::COLUMNS[column] + .iter() + .all(|&index| landing[column].start[index].to_bits() == ended[index].to_bits()), + "the landing starts where the fade ended, column {column}" + ); + } + for block in 1_216 / QUANTUM..1_664 / QUANTUM { + assert_eq!(by_block[block], landing, "block {block}: the landing runs"); + } + let grid = by_block[1_664 / QUANTUM]; + for column in 0..2 { + assert!( + aims( + &grid[column], + open(OPEN, law(curve(1_664 + 63), 0.0)), + 64, + column + ), + "the grid moves the column again, column {column}" + ); + } + } + + /// R19-3 gate (b): with the left column's `ll` riding and the source's right mute stepping on + /// at 1,000, the right column fades over the mute length while the left column keeps + /// retargeting at every grid point over the grid ramp: the hold is per column. + /// + /// Red if a column jump of one column holds the other column's grid (a per-route hold). + #[test] + fn a_ride_on_the_left_column_keeps_moving_while_the_right_column_fades() { + let ride = vec![seg(0, 8_000, 0.1, 0.9, Shape::Linear)]; + let curves = Curves { + source: [None, Some(step(1_000, 0.0, 1.0))], + matrix: [Some(ride.clone()), None, None, None], + ..Curves::default() + }; + let transform = RouteTransform { gain: 0.5, ..OPEN }; + let mut send = Send::new(&curves, transform); + let by_block = ramps_by_block(&mut send, 20); + let fade = by_block[1_000 / QUANTUM][1]; + assert_eq!( + fade.length, 200, + "the right column fades over the mute length" + ); + assert_eq!(fade.target[1].to_bits(), 0.0_f32.to_bits()); + assert_eq!(fade.target[3].to_bits(), 0.0_f32.to_bits()); + for block in 1_000 / QUANTUM..1_200 / QUANTUM { + assert_eq!( + by_block[block][1], fade, + "block {block}: the right column fades" + ); + let left = by_block[block][0]; + let grid = (block * QUANTUM) as i64; + let ll = automation::value_at(&ride, grid + 63); + let target = + gated_route_coefficients(&RouteTransform { ll, ..transform }, RouteGate::OPEN); + assert!( + aims(&left, target, 64, 0), + "block {block}: the left column rides on" + ); + } + } + + /// R19-4 gate: an unmute of the send's own mute and a step of its gain at one offset (1,000) + /// are one retarget of each column, over the longer of the two lengths, whichever row owns it. + /// + /// Red if coincident events take one row's length regardless, or retarget twice. + #[test] + fn coincident_send_events_take_the_longest_jump() { + let curves = Curves { + gain: Some(vec![ + seg(0, 1_000, -6.0, -6.0, Shape::Step), + seg(1_000, 9_000, -12.0, -12.0, Shape::Step), + ]), + mute: Some(step(1_000, 1.0, 0.0)), + ..Curves::default() + }; + for (lengths, longest) in [((480, 200), 480), ((480, 960), 960)] { + let mut send = Send::new(&curves, OPEN); + let lengths = GraphJumpLengths { + fader_samples: lengths.0, + mute_samples: lengths.1, + pan_samples: 960, + }; + let mut ramps = Vec::new(); + for index in 0..20_u64 { + let (mut left, mut right) = input(QUANTUM); + let time = GraphBlockTime::new( + Some(index * QUANTUM as u64), + QUANTUM as u32, + Some(lengths), + Some(&Timeline), + ); + if send + .route + .begin_block(send.gate.snapshot(), &time) + .is_some() + { + send.route.mix(&mut left, &mut right); + } + ramps.push(send.route.ramps.ramps); + } + let target = open(OPEN, law(-12.0, 0.0)); + let jump = ramps[1_000 / QUANTUM]; + for column in 0..2 { + assert!(aims(&jump[column], target, longest, column), "{lengths:?}"); + assert!( + super::COLUMNS[column] + .iter() + .all(|&index| jump[column].start[index].to_bits() == 0.0_f32.to_bits()), + "{lengths:?}: column {column} from silence" + ); + } + assert!( + ramps[1_000 / QUANTUM + 1..] + .iter() + .all(|ramp| *ramp == jump) + ); + } + } + + /// R19-3, a value jump inside a column jump (coordinator question, batch 19): the send's own + /// unmute at 1,000 holds the riding `ll` until 1,200; the gain steps at 1,100 and jumps over its + /// own length (480, to 1,579), replacing the column ramp. The held `ll` waits for the new + /// ramp's end: nothing retargets the left column in between, and at 1,580 `ll` lands over the + /// matrix length (960). + /// + /// Red if a value cursor deferred to a replaced column ramp's end lands at that old end, inside + /// another curve's jump (a re-time of the gain step). + #[test] + fn a_held_value_waits_for_the_end_of_a_jump_that_replaced_the_column_ramp() { + let ride = vec![seg(0, 8_000, 0.1, 0.9, Shape::Linear)]; + let curves = Curves { + gain: Some(vec![ + seg(0, 1_100, -6.0, -6.0, Shape::Step), + seg(1_100, 9_000, -12.0, -12.0, Shape::Step), + ]), + mute: Some(step(1_000, 1.0, 0.0)), + matrix: [Some(ride.clone()), None, None, None], + ..Curves::default() + }; + let mut send = Send::new(&curves, OPEN); + let by_block = ramps_by_block(&mut send, 30); + let step = by_block[1_100 / QUANTUM][0]; + assert_eq!(step.length, 480, "the gain step jumps over its own length"); + for block in 1_100 / QUANTUM..1_580 / QUANTUM { + assert_eq!( + by_block[block][0], step, + "block {block}: the gain's jump runs whole" + ); + } + let landing = by_block[1_580 / QUANTUM][0]; + let ll = automation::value_at(&ride, 1_580 + 959); + let target = open(RouteTransform { ll, ..OPEN }, law(-12.0, 0.0)); + assert!( + aims(&landing, target, 960, 0), + "the held ll lands at the jump's end" + ); + } + + /// A hand offset record on a riding send gain (+3 dB over 256 at node time 2,048, #1654 R17-6 + /// on the route, #1649 D3): both columns retarget at the block entry toward the curve at the + /// record's last frame with the new offset, the grid is held through the record's ramp, and + /// at its end (2,304) the gain lands over its own length. + /// + /// Red if the record aims at the curve at its first frame, if the grid cuts the record's ramp, + /// or if the held ride is not landed at its end. + #[test] + fn a_hand_offset_record_aims_at_the_curve_at_its_end_and_holds_the_grid() { + let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let curves = Curves { + gain: Some(ride.clone()), + ..Curves::default() + }; + let curve = |t: i64| automation::value_at(&ride, t); + let mut send = Send::new(&curves, OPEN); + let mut ramps = ramps_by_block(&mut send, 32); + send.write(RouteTransform { gain: 3.0, ..OPEN }, 256); + ramps.extend((32..48).map(|index| { + send.run(index * QUANTUM as u64); + send.route.ramps.ramps + })); + let record = ramps[32]; + let aimed = open(OPEN, law(curve(2_048 + 255), 3.0)); + for column in 0..2 { + assert!( + aims(&record[column], aimed, 256, column), + "the record, column {column}" + ); + } + for block in 33..36 { + assert_eq!( + ramps[block], record, + "block {block}: the record's ramp runs whole" + ); + } + let landing = ramps[36]; + let landed = open(OPEN, law(curve(2_304 + 479), 3.0)); + for column in 0..2 { + assert!( + aims(&landing[column], landed, 480, column), + "the landing, column {column}" + ); + } + } + + /// One plan swap at the start of block `at` (#1654 D3): `successor` takes `predecessor`'s + /// route state, as `carry_live_routes` does per move. + fn swap(successor: &mut Send, predecessor: &mut Send) { + let pending = super::carry_route( + super::CarriedRoute::Seven(&mut successor.route), + super::CarriedRoute::Seven(&mut predecessor.route), + None, + ); + assert!(!pending); + } + + /// D3, a continued table: a send whose gain rides, carried mid-ride into a successor whose + /// gain row continues the table, keeps its cursor verbatim: every block after the swap is the + /// never-swapped twin's, bit for bit. + /// + /// Red if a continued cursor is placed again at the swap (its ride would jump over the gain + /// length), or if its state or heard value is not carried. + #[test] + fn a_continued_send_ride_carries_its_cursor_verbatim() { + let curves = Curves { + gain: Some(vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]), + matrix: [ + None, + Some(vec![seg(0, 8_000, 0.2, 0.7, Shape::Linear)]), + None, + None, + ], + ..Curves::default() + }; + let mut twin = Send::new(&curves, OPEN); + let mut predecessor = Send::new(&curves, OPEN); + for index in 0..21_u64 { + assert_eq!(twin.run(index * 64), predecessor.run(index * 64)); + } + let mut successor = + Send::continuing(&curves, OPEN, [true, false, false, true, false, false]); + swap(&mut successor, &mut predecessor); + for index in 21..60_u64 { + assert_eq!( + successor.run(index * 64), + twin.run(index * 64), + "block {index}" + ); + } + } + + /// D3, a moved table: a settled send whose gain table changes at a swap (flat -6 dB to flat + /// -3 dB, the row not continued) jumps at the successor's first sample over the gain length. + /// + /// Red if the moved row lands as a step, over another row's length, or not at all (the + /// cursor's comparison with the placed value instead of the value the column heard). + #[test] + fn a_moved_send_table_jumps_over_its_own_length_at_the_first_sample() { + let before = Curves { + gain: Some(flat(-6.0)), + ..Curves::default() + }; + let after = Curves { + gain: Some(flat(-3.0)), + ..Curves::default() + }; + let mut predecessor = Send::new(&before, OPEN); + for index in 0..20_u64 { + predecessor.run(index * 64); + } + let mut successor = Send::new(&after, OPEN); + swap(&mut successor, &mut predecessor); + successor.run(20 * 64); + let target = open(OPEN, law(-3.0, 0.0)); + for column in 0..2 { + let ramp = successor.route.ramps.ramps[column]; + assert!(aims(&ramp, target, 480, column), "column {column}"); + assert_eq!(successor.route.ramps.positions[column], 64); + } + } + + /// R19-5: a hand offset record in flight at a swap whose gain table moves (flat -6 dB to flat + /// -3 dB) keeps its own length: the column ramp runs to its end (3,152), where the moved row + /// jumps over the gain length to the new table with the offset (#1711's deferred jump, per + /// column, no flag). + /// + /// Red if the swap lands the moved table at once (cutting the hand move), or never. + #[test] + fn a_hand_move_in_flight_at_a_swap_keeps_its_length_and_the_moved_table_lands_after_it() { + let before = Curves { + gain: Some(flat(-6.0)), + ..Curves::default() + }; + let after = Curves { + gain: Some(flat(-3.0)), + ..Curves::default() + }; + let moved = RouteTransform { gain: 3.0, ..OPEN }; + let mut predecessor = Send::new(&before, OPEN); + for index in 0..18_u64 { + predecessor.run(index * 64); + } + predecessor.write(moved, 2_000); + for index in 18..20_u64 { + predecessor.run(index * 64); + } + let record = predecessor.route.ramps.ramps; + let mut successor = Send::new(&after, moved); + swap(&mut successor, &mut predecessor); + for index in 20..49_u64 { + successor.run(index * 64); + assert_eq!( + successor.route.ramps.ramps, record, + "block {index}: the move runs" + ); + } + successor.run(49 * 64); + let target = open(OPEN, law(-3.0, 3.0)); + for column in 0..2 { + let ramp = successor.route.ramps.ramps[column]; + assert!(aims(&ramp, target, 480, column), "column {column}"); + // The jump is at 3,152, offset 16 of the block from 3,136. + assert_eq!(successor.route.ramps.positions[column], 48); + } + } +} From 4533703d3ac306b8fdd6c76aff565bac78700197 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 13:59:48 +0000 Subject: [PATCH 14/36] Gate the stale-deferral fix on the C ABI and both hosts (#1711 U1 fold 1, part 2) C ABI and both-host gates for a table step inside a deferred move's ramp (red with the fix reverted); the outcome-only two-rebuild test is deleted (its no-move EXACT check moves into G1); the deleted mute-curve parity case's bit check is restored; the G2 parity test now carries the committed hand offset so the deferral is exercised on both hosts. Co-Authored-By: Claude Opus 5.5 --- ...ves-own-length-across-a-reposition-swap.md | 44 ++- crates/capi/src/runtime/live_tests.rs | 181 +++++----- hosts/host-web/tests/control_parity.rs | 315 ++++++++++++++++-- 3 files changed, 417 insertions(+), 123 deletions(-) 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 223954ab6..db78fee33 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 @@ -450,11 +450,6 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af ## Decision record: U1 fold 1 (batch 19) -- **Base.** The fold worktree is at `202724b74` (K1), which does not contain F3/F4 (`13abb0cc0`, - `1872b54c2`, on `codex/controls-batch-19f` at `14027678f`); the two merge cleanly (merge-tree - `00c6abf3d`, and this fold's diff applies to it). MAJOR-1's fix, its host-core gate and MINOR-2 - are folded here. The C ABI and both-host MAJOR-1 legs, MINOR-1 and the NIT edit files that F3/F4 - rewrote and are left for the merged base. - **MAJOR-1 fixed (A2 clear list).** `crates/automation/src/events.rs`, `next_in_run`: a discontinuity jump on a restartable cell drops a pending deferral (the jump compares the curve with the target and replaces the ramp the deferral waits for; the stale deferral's grid hold @@ -473,6 +468,34 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af rebuild `[-5 dB step 0..2,000; linear -8 to -14 dB 2,000..8,000; -14 dB]` against a fresh plan with -3 dB before 2,000: every sample bit-equal, allocation-free, adopted, carried. Red without the fix: first difference sample 2,944, max 2.9e-2 (all four cases). +- **MAJOR-1, C ABI (Simd8).** `a_table_step_inside_a_deferred_moves_ramp_drops_the_deferral` + (`crates/capi/src/runtime/live_tests.rs`, through `rebuilt_table_case`, which + `moved_target_case` now calls): VCA and hand moves, heard 1 and 4, every sample bit-equal to the + fresh plan, allocation-free, adopted, `OUTCOME_EXACT` with no transition fallback. Red without + the fix (VCA move, heard 1). +- **MAJOR-1, both hosts.** `a_table_step_inside_a_deferred_moves_ramp_lands_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`): hand move heard 3 blocks, the same rebuild; one + verdict, reply and committed document per edit, the same bits and watermark (`EXACT`, no + fallback), and every block equal to a reference pair prepared with -3 dB before 2,000 that takes + the same move and an unmoved table-only rebuild at the same block (both rebuilds keep the hand + move's stored offset; a rebuild with the default offset cancels the move). Red without the fix + (44.1 kHz, block 26). +- **G2 parity, fixed.** `a_moved_target_defers_its_jump_alike_on_both_hosts` sent its rebuild + with the default offset, which cancelled the hand move (parity held, the deferral was not + exercised). Its rebuild now carries the committed offset (read from both hosts' snapshots, one + document), and every block before the one holding 4,512 equals a pair given the same move and + no rebuild; a later block differs (the jump is heard). Red once with the adoption landing at the + swap instead of deferring (`settle_adoption`, 44.1 kHz, block 10). A fresh-plan reference with a + real step at 4,512 is not bit-equal in this session (first difference about `J / 2` after + 4,512 at every rate: 4,959, 4,998, 5,400 and 5,478); not chased here, phase-2 line in the + report. +- **MINOR-1.** `table_only_rebuilds_around_a_level_move_complete_exactly` is deleted. Its + no-live-move case moved into G1, `a_level_move_across_a_table_change_keeps_its_length`: a + table-only rebuild with no move is adopted and reports `OUTCOME_EXACT` (outcome only: the split + table's rounding differs). The host-web renamed G1 parity test keeps its own no-move check. +- **NIT.** `a_mute_curve_rebuild_while_a_level_move_ramps_renders_alike_on_both_hosts` + (`control_parity.rs`) restores the deleted test's both-host part: verdicts, replies, the same + bits over 40 blocks and the same watermark; no B17-R14 window claim. - **MINOR-2 gate (host-core, Simd8 and Simd4).** `a_hand_mute_inside_a_pending_deferral_lands_the_lane_on_the_new_table_at_the_unmute`: G2 heard 12 (swap 2,048, F 4,512), hand mute at 2,560, unmute at 3,072 or 5,120, ramps 200 and 0. @@ -483,9 +506,8 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af is red (exact unmute); with all three builtins-compiler clears removed the ramped unmute stays green, because its owed landing at `E` is a seek landing that clears the deferral. The `release_lane_hold` sites are not reached by this setup. -- Gates run (own target dir: the shared one held another worktree's `graph` build): `cargo fmt - --all`; clippy `-D warnings --all-targets --all-features` and no-feature `cargo check - --all-targets` on automation and host-core; tests: automation (43, compose included), - builtins-compiler (all features), host-core `stored_fader_automation` (35), - `stored_mute_automation` (13), `successor_swap` (140), capi lib (211), host-web - `control_parity` (31): green. Realtime policy: 171 regions (unchanged). +- Gates run: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` and no-feature + `cargo check --all-targets` on automation, builtins-compiler, host-core, capi and host-web; + tests: automation (43, compose included), builtins-compiler (all features), host-core + `stored_fader_automation` (36), `stored_mute_automation` (13), `successor_swap` (140), capi + lib (211), host-web `control_parity` (33): green. Realtime policy: 171 regions (unchanged). diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 21b7220dc..16dc2c576 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -9064,7 +9064,8 @@ mod set_control { /// left, the last three shorter than the fader length `J` = 960). Every block of the /// rebuilt rig is bit-equal to the uninterrupted engine's over 44 blocks, allocation-free, /// the successor is adopted, and the rebuild's watermark advance reports `OUTCOME_EXACT` - /// with no transition fallback (#1711 D2: the B17-R14 flag is deleted). + /// with no transition fallback (#1711 D2: the B17-R14 flag is deleted). A table-only + /// rebuild with no live move reports `OUTCOME_EXACT` too (outcome only). /// /// Test value: red if the adoption rule compares the curve with the stage's fader gain /// rather than its ramp's target (#1711 F0: the held grid events only remember a gain, so @@ -9088,6 +9089,24 @@ mod set_control { (SOURCE_FRAMES - 2_000, -6.0), ), ]; + // A table-only rebuild with no live move completes exactly too (bits are not + // compared: the split table's values differ from the ride's in `f32` rounding). + let mut still = Rig::new(&document); + for _ in 0..4 { + still.step(); + } + let offset = still.model().automation[0].offset; + send( + &mut still, + &[split_ride(&track, changes[0].1, offset)], + protocol::EditPath::Rebuild, + "no move", + ); + for _ in 0..4 { + still.step(); + } + assert_eq!(still.summary().3, 0, "no move: the successor is adopted"); + assert_exact(&still, "no move"); let timings = [ MoveTiming::Pending, MoveTiming::SameTransaction, @@ -9274,25 +9293,6 @@ mod set_control { } } - /// A step ride of track `track` through `(start, end, dB)` pieces, with the stored - /// `offset`. - fn steps_ride( - track: &str, - pieces: &[(u64, u64, f32)], - offset: session::AutomationOffset, - ) -> session::Automation { - let step = session::AutomationShape::Step; - let mut automation = entry( - track, - pieces - .iter() - .map(|&(start, end, db)| segment(step, (start, end), (db, db))) - .collect(), - ); - automation.offset = offset; - automation - } - /// One moved-target case of #1711 G2 and R19-1 on the C ABI: on [`flat`]'s ride (-3 dB), /// a live level move to -6 dB over its own 4,000 samples from block 4 (a VCA move or a /// hand move), heard `heard` blocks, then a table-only rebuild to -5 dB up to 6,000, then @@ -9304,11 +9304,47 @@ mod set_control { /// allocation-free, the successor is adopted, and every revision completed exactly (the /// rebuild's and the later moves': `OUTCOME_EXACT`, no transition fallback). fn moved_target_case(hand: bool, heard: usize, later: &[(u64, f32, u32)], from: u64) { + let free = 4 * 128 + 4_000; + let pieces = |pieces: &[(u64, u64, f32)]| { + let step = session::AutomationShape::Step; + pieces + .iter() + .map(|&(start, end, db)| segment(step, (start, end), (db, db))) + .collect::>() + }; + let table = pieces(&[(0, 6_000, -5.0), (6_000, SOURCE_FRAMES, -3.0)]); + let fresh_table = pieces(&[ + (0, free, -3.0), + (free, 6_000, -5.0), + (6_000, SOURCE_FRAMES, -3.0), + ]); + let reference = if later.is_empty() { + fresh_table + } else { + table.clone() + }; + rebuilt_table_case(hand, heard, (&table, &reference), later, from); + } + + /// [`moved_target_case`] with the rebuild's `table` and the fresh plan's `reference` + /// table (both track 0's ride segments) on [`flat`]'s ride. + fn rebuilt_table_case( + hand: bool, + heard: usize, + (table, reference): (&[session::AutomationSegment], &[session::AutomationSegment]), + later: &[(u64, f32, u32)], + from: u64, + ) { const MOVE: u32 = 4_000; bench_support::alloc::assert_installed(); let document = flat(); let track = first_track(&document); let label = &format!("hand {hand}, heard {heard}, {} later moves", later.len()); + let ride = |segments: &[session::AutomationSegment], offset| { + let mut automation = entry(&track, segments.to_vec()); + automation.offset = offset; + automation + }; let move_of = |document: &str, db: f32, ramp: u32| { if hand { hand_move(document, db, Some(ramp)) @@ -9317,23 +9353,8 @@ mod set_control { } }; let mut a = Rig::new(&document); - let free = 4 * a.quantum as u64 + u64::from(MOVE); - let table = [(0, 6_000, -5.0), (6_000, SOURCE_FRAMES, -3.0)]; - let fresh_table = [ - (0, free, -3.0), - (free, 6_000, -5.0), - (6_000, SOURCE_FRAMES, -3.0), - ]; let mut model = parse_session_json(&document).expect("model"); - model.automation[0] = steps_ride( - &track, - if later.is_empty() { - &fresh_table - } else { - &table - }, - session::AutomationOffset::default(), - ); + model.automation[0] = ride(reference, session::AutomationOffset::default()); let fresh = session::canonical_session_json(&model).expect("canonical"); let mut r = Rig::new(&fresh); for rig in [&mut a, &mut r] { @@ -9362,7 +9383,7 @@ mod set_control { send( &mut a, &[SessionEdit::UpsertAutomation { - automation: steps_ride(&track, &table, offset), + automation: ride(table, offset), }], protocol::EditPath::Rebuild, label, @@ -9445,6 +9466,36 @@ mod set_control { } } + /// #1711 U1 fold 1 (MAJOR-1) on the C ABI. A VCA or hand move heard 1 and 4 blocks on + /// the ride flat at -3 dB, then a table-only rebuild to -5 dB up to 2,000, a linear -8 to + /// -14 dB over 2,000 to 8,000, then -14 dB: the move's target moves, so its jump is + /// deferred to the move's first free sample (4,512), and the table's step at 2,000, inside + /// the move's remaining ramp, replaces the ramp the deferral waits for. Every sample is + /// bit-equal to a fresh plan whose table is -3 dB before 2,000 (the same after it), given + /// the same move; every revision completes as `OUTCOME_EXACT`. + /// + /// Test value: red if a discontinuity jump on a restartable cell leaves the pending + /// deferral in place (its grid hold freezes the lane on the step's target until 4,512). + #[test] + fn a_table_step_inside_a_deferred_moves_ramp_drops_the_deferral() { + let stepped = |first: f32| { + let (step, lin) = ( + session::AutomationShape::Step, + session::AutomationShape::Linear, + ); + vec![ + segment(step, (0, 2_000), (first, first)), + segment(lin, (2_000, 8_000), (-8.0, -14.0)), + segment(step, (8_000, SOURCE_FRAMES), (-14.0, -14.0)), + ] + }; + for hand in [false, true] { + for heard in [1, 4] { + rebuilt_table_case(hand, heard, (&stepped(-5.0), &stepped(-3.0)), &[], 0); + } + } + } + /// The outcome flags and the transition-fallback count of `rig`'s watermark. fn outcome(rig: &Rig) -> (u64, u64) { let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); @@ -9469,62 +9520,6 @@ mod set_control { ); } - /// #1711 A3 (D2). A table-only rebuild of the riding fader with no live move, and two - /// table-only rebuilds 5 blocks apart while a live VCA move of 500 samples ramps (heard 2 - /// blocks before the first; the second comes after the move's own ramp has ended): each - /// rebuild's watermark advance reports `OUTCOME_EXACT` with no transition fallback, every - /// block is allocation-free, and each successor is adopted. Bits are not compared: the - /// split tables' values differ from the ride's in `f32` rounding, so the adopting cell - /// jumps over `J` where a fresh plan does not (the F2 decision record). - /// - /// Test value: red if a Reposition rebuild with a level move in flight, or one with no - /// move, completes as the transition fallback (the B17-R14 flag, or any successor). - #[test] - fn table_only_rebuilds_around_a_level_move_complete_exactly() { - bench_support::alloc::assert_installed(); - let document = riding(); - let track = first_track(&document); - let offset = session::AutomationOffset::default(); - for moving in [false, true] { - let label = &format!("a move {moving}"); - let mut a = Rig::new(&document); - for _ in 0..4 { - a.step(); - } - if moving { - send( - &mut a, - &[vca_move(-6.0, Some(500))], - protocol::EditPath::Live, - label, - ); - for _ in 0..2 { - a.step(); - } - } - for (rebuild, end_db) in [("R1", -6.0), ("R2", -9.0)] { - let label = &format!("{label}, {rebuild}"); - send( - &mut a, - &[split_ride(&track, (10_000, end_db), offset)], - protocol::EditPath::Rebuild, - label, - ); - for _ in 0..5 { - let block = a.block; - let (_, used) = a.step_counted(); - assert_eq!( - (used.allocations, used.deallocations), - (0, 0), - "{label}: block {block} allocated or freed" - ); - } - assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); - assert_exact(&a, label); - } - } - } - /// U3 fold 2, MINOR (#1648 D2, D15-2). Two live VCA moves around a rebuild, with no block /// between any of them: a move pending at a rebuild whose own transaction moves `v` /// again, and a move, a rebuild, then a second live move. The first move is replaced diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index c027a58d2..47673a7cd 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -3016,17 +3016,23 @@ fn a_hand_move_in_flight_across_a_table_only_rebuild_completes_exactly_alike_on_ } /// #1711 G2 across hosts: on a fader ride flat at -3 dB, a hand move with its own 4,000 samples -/// heard 3 blocks, then a table-only rebuild to -5 dB (the move's target moves): the adoption -/// defers the cell's jump to the move's first free sample on both hosts alike, through the one -/// shared driver. Both hosts give one verdict and reply per edit and render the same bits in every -/// block, through the move's end and the deferred jump over the fader length, and the rebuild -/// completes exactly on both (`OUTCOME_EXACT`, no transition fallback; #1711 D2). +/// heard 3 blocks, then a table-only rebuild to -5 dB up to 20,000, then -3 dB (the move's target +/// moves; the rebuild keeps the hand move's stored offset, R17-1): the adoption defers the cell's +/// jump to the move's first free sample (4,512) on both hosts alike, through the one shared +/// driver. Both hosts give one verdict, reply and committed document per edit, render the same +/// bits in every block, through the move's end and the deferred jump over the fader length, and +/// the rebuild completes exactly on both (`OUTCOME_EXACT`, no transition fallback; #1711 D2). +/// Every block before the one holding 4,512 equals a pair that takes the same move and no +/// rebuild (the move keeps its own ramp), and a later block differs from it (the jump is heard). /// /// Test value: red if either host adopts the moved target differently (an adapter-local rule, or -/// a browser successor that does not carry the stage's ramp the deferral waits for). +/// a browser successor that does not carry the stage's ramp the deferral waits for), or if the +/// shared adoption does not defer the jump to the move's first free sample (it lands at the swap, +/// or the grid cuts the move short). #[test] fn a_moved_target_defers_its_jump_alike_on_both_hosts() { - let ride = |pieces: &[(u64, u64, f32)]| SessionEdit::UpsertAutomation { + const FREE: u64 = 4 * QUANTUM as u64 + 4_000; + let ride = |pieces: &[(u64, u64, f32)], offset| SessionEdit::UpsertAutomation { automation: session::Automation { id: id("t1-fader-ride"), target: address( @@ -3046,9 +3052,10 @@ fn a_moved_target_defers_its_jump_alike_on_both_hosts() { unit: session::ParameterUnit::Db, }) .collect(), - offset: session::AutomationOffset::default(), + offset, }, }; + let none = session::AutomationOffset::default(); let hand = CAbi::edits( Edit::Fader { strip: T1, @@ -3059,24 +3066,73 @@ fn a_moved_target_defers_its_jump_alike_on_both_hosts() { ); 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 (mut browser, mut c_abi) = (Rig::new(&model), CAbi::new(&model)); + let (mut browser_r, mut c_abi_r) = (Rig::new(&model), CAbi::new(&model)); + let both = |edits: &[SessionEdit], browser: &mut Rig, c_abi: &mut CAbi, label: &str| { 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"); + assert_eq!(b, c, "{rate} Hz, {label}: one verdict"); + assert_eq!(b_reply, c_reply, "{rate} Hz, {label}: one reply"); }; - both(&[ride(&[(0, 1_000_000, -3.0)])], &mut browser, &mut c_abi); - same_blocks(&mut browser, &mut c_abi, 0..4, "the flat ride"); - both(&hand, &mut browser, &mut c_abi); - same_blocks(&mut browser, &mut c_abi, 4..7, "the hand move"); both( - &[ride(&[(0, 20_000, -5.0), (20_000, 1_000_000, -3.0)])], + &[ride(&[(0, 1_000_000, -3.0)], none)], &mut browser, &mut c_abi, + "the flat ride", + ); + both( + &[ride(&[(0, 1_000_000, -3.0)], none)], + &mut browser_r, + &mut c_abi_r, + "the unrebuilt ride", ); - same_blocks(&mut browser, &mut c_abi, 7..60, "the moved target"); + let mut heard = false; + for block in 0..60 { + if block == 4 { + both(&hand, &mut browser, &mut c_abi, "the hand move"); + both(&hand, &mut browser_r, &mut c_abi_r, "the unrebuilt move"); + } + if block == 7 { + let committed = + snapshot_bytes(|payload, expected| browser.command(payload, expected)); + assert_eq!( + committed, + snapshot_bytes(|payload, expected| c_abi.send(payload, expected)), + "{rate} Hz: one committed document" + ); + let committed = + session::parse_session_json(core::str::from_utf8(&committed).expect("utf-8")) + .expect("the committed document"); + let offset = committed + .automation + .iter() + .find(|entry| entry.id == id("t1-fader-ride")) + .expect("the ride") + .offset; + assert_ne!(offset, none, "{rate} Hz: the hand move is stored"); + both( + &[ride( + &[(0, 20_000, -5.0), (20_000, 1_000_000, -3.0)], + offset, + )], + &mut browser, + &mut c_abi, + "the rebuild", + ); + } + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{rate} Hz: block {block}"); + let (b_r, c_r) = (browser_r.step(block), c_abi_r.step(block)); + assert_eq!(b_r, c_r, "{rate} Hz: block {block}, the unrebuilt move"); + if block < FREE / u64::from(QUANTUM) { + assert_eq!( + b, b_r, + "{rate} Hz: block {block} against the unrebuilt move" + ); + } + heard |= b != b_r; + } + assert!(heard, "{rate} Hz: the deferred jump is heard"); let watermark = c_abi.watermark(); assert_eq!( status_watermark(&browser), @@ -3100,6 +3156,227 @@ fn a_moved_target_defers_its_jump_alike_on_both_hosts() { } } +/// #1711 U1 fold 1 (MAJOR-1) across hosts: on a fader ride flat at -3 dB, a hand move with its +/// own 4,000 samples heard 3 blocks, then a table-only rebuild to -5 dB up to 2,000, a linear -8 +/// to -14 dB over 2,000 to 8,000, then -14 dB. The moved target defers the cell's jump to the +/// move's first free sample (4,512), and the table's step at 2,000, inside the move's ramp, +/// replaces the ramp the deferral waits for. Both hosts give one verdict and reply per edit, the +/// same bits in every block and the same watermark (`EXACT`, no transition fallback), and every +/// block equals the pair of rigs prepared with -3 dB before 2,000 (the same table after it), +/// given the same move and, at the same block, a table-only rebuild that moves nothing (a split +/// at 950,000), at the four rates. Both rebuilds keep the hand move's stored offset. +/// +/// Test value: red if a discontinuity jump on a restartable cell leaves the pending deferral in +/// place (its grid hold freezes the lane on the step's target until 4,512), on either host. +#[test] +fn a_table_step_inside_a_deferred_moves_ramp_lands_alike_on_both_hosts() { + let segment = |shape, start, end, from, to| session::AutomationSegment { + shape, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: session::ParameterUnit::Db, + }; + let ride = |segments: Vec, offset| SessionEdit::UpsertAutomation { + automation: session::Automation { + id: id("t1-fader-ride"), + target: address( + Owner::Strip(id(T1.id)), + Section::Builtins, + 5, + ParameterChannel::Both, + ), + segments, + offset, + }, + }; + let (step, linear) = ( + session::AutomationShape::Step, + session::AutomationShape::Linear, + ); + let stepped = |first: f32, split: u64, offset| { + ride( + vec![ + segment(step, 0, 2_000, first, first), + segment(linear, 2_000, 8_000, -8.0, -14.0), + segment(step, 8_000, split, -14.0, -14.0), + segment(step, split, 1_000_000, -14.0, -14.0), + ], + offset, + ) + }; + let none = 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, mut c_abi) = (Rig::new(&model), CAbi::new(&model)); + let (mut browser_r, mut c_abi_r) = (Rig::new(&model), CAbi::new(&model)); + let both = |edits: &[SessionEdit], browser: &mut Rig, c_abi: &mut CAbi, label: &str| { + let (b, b_reply) = browser.transaction_reply(edits); + let (c, c_reply) = c_abi.transaction(edits); + assert_eq!(b, c, "{rate} Hz, {label}: one verdict"); + assert_eq!(b_reply, c_reply, "{rate} Hz, {label}: one reply"); + }; + both( + &[ride(vec![segment(step, 0, 1_000_000, -3.0, -3.0)], none)], + &mut browser, + &mut c_abi, + "the flat ride", + ); + both( + &[stepped(-3.0, 900_000, none)], + &mut browser_r, + &mut c_abi_r, + "the reference ride", + ); + for block in 0..60 { + if block == 4 { + both(&hand, &mut browser, &mut c_abi, "the hand move"); + both(&hand, &mut browser_r, &mut c_abi_r, "the reference move"); + } + if block == 7 { + // A table-only rebuild keeps the hand move's stored offset (R17-1). + let committed = + snapshot_bytes(|payload, expected| browser.command(payload, expected)); + assert_eq!( + committed, + snapshot_bytes(|payload, expected| c_abi.send(payload, expected)), + "{rate} Hz: one committed document" + ); + let committed = + session::parse_session_json(core::str::from_utf8(&committed).expect("utf-8")) + .expect("the committed document"); + let offset = committed + .automation + .iter() + .find(|entry| entry.id == id("t1-fader-ride")) + .expect("the ride") + .offset; + both( + &[stepped(-5.0, 900_000, offset)], + &mut browser, + &mut c_abi, + "the rebuild", + ); + both( + &[stepped(-3.0, 950_000, offset)], + &mut browser_r, + &mut c_abi_r, + "the reference rebuild", + ); + } + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{rate} Hz: block {block}"); + let (b_r, c_r) = (browser_r.step(block), c_abi_r.step(block)); + assert_eq!(b_r, c_r, "{rate} Hz: block {block}, the reference"); + assert_eq!(b, b_r, "{rate} Hz: block {block} against the reference"); + } + 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: outcome flags"); + 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 gate 8 across hosts (U1 fold 1 NIT of #1711, restored without its B17-R14 window +/// claim): 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. Both +/// hosts give one verdict and reply per edit, render the same bits in every block and report the +/// same watermark. +/// +/// Test value: red if either host adopts a mute-curve rebuild under a ramping level move +/// differently (an adapter-local adoption, or a browser successor that does not carry the +/// fader stage's ramp or the mute cell's state as the C ABI's does). +#[test] +fn a_mute_curve_rebuild_while_a_level_move_ramps_renders_alike_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: [(0, close, 0.0), (close, 1_000_000, 1.0)] + .iter() + .map(|&(start, end, value)| session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: start, + end_sample: end, + start_value: value, + end_value: value, + unit: session::ParameterUnit::Linear, + }) + .collect(), + 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..40, "the mute curve rebuild"); + assert_eq!( + status_watermark(&browser), + c_abi.watermark(), + "{rate} Hz: the browser status carries the C ABI's watermark" + ); + } +} + /// #1711 G4 across hosts (A1): an exact hand unmute that frees a lane held over a moving ride /// discharges the move the hold owed, alike on both hosts. `t1` rides linearly from 0 to -6 dB /// over `[0, 1,280)`, holds -6 dB to 2,320, then rides on to -12 dB. A hand mute (ramp 256) at From 8b533bd5556f2f55fd848217b7a70f7ce76b81cc Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 14:27:31 +0000 Subject: [PATCH 15/36] Integrate send row render and gate it on both hosts (#1654 T5) One continued-rows path (the source's, which render reads); the route op seeds an automated gain from the hand offset; a route row the predecessor did not automate composes the join record with its curve at the first carried block and keeps the record's column hold. Gates 1-8 and R19-5 on the C ABI, both hosts and host-core at Simd8, Simd4 and the scalar oracle; the schema doc says send rows render and the send gain takes the offset. Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 69 +++ crates/capi/src/runtime/live_tests.rs | 450 ++++++++++++++++++ crates/graph/src/lib.rs | 6 - crates/graph/src/runtime.rs | 164 ++++++- crates/host-core/src/prepare.rs | 34 +- crates/host-core/tests/route_mute.rs | 249 ++++++++++ docs/SESSION_SCHEMA_V1.md | 29 +- hosts/host-web/tests/control_parity.rs | 450 ++++++++++++++++++ 8 files changed, 1416 insertions(+), 35 deletions(-) diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 3be81a4f8..05a3b36e6 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -670,3 +670,72 @@ For T3/T5: the route op must read `source.gain_offset()` as the seeded `gain` wo set (the bound transform's `gain` is the compiler's linear static gain) and `GraphRouteMove::continued_rows` for the carry. A route-row-only send now binds the seven-word lane with no source cells, and renders with the old (static) route op until T3 lands. + +## Decision record: T5 (batch 19) + +Integration of T3 and T4, the gates and the doc. Nothing under `crates/engine/src/realtime/`; no +`crates/lane` change; no row class or table changed (no SDK regeneration). + +- **One continued-rows path.** Render reads `GraphFollowRouteSource::continued` (T3), so that path + stays: host-core's attach now sets `with_continued_rows(controls::continued_route_rows( + base.committed, model, route))` for a successor (the route joins are computed before the attach). + T4's `GraphRouteMove::continued_rows` and `RouteJoin::continued_rows` are deleted; + `GraphRouteMove` 16 -> 12 bytes (x86-64), `RouteCarryStep` unchanged. +- **Gain word seed (T4 note).** `LiveFollowRoute::new` replaces the bound transform's `gain` (the + compiler's linear static gain, a fallback) with `source.gain_offset()` when the flags automate + the gain. Before it, a flat ride at -6 dB rendered `law(-6, 1.0)` instead of the static bits. +- **T3 edge: it was a heard difference, fixed.** A row the predecessor did not automate + (`Placement::New`) kept its value cursor's bind seed (the curve at node time 0) through the + carried first block's cell, so a gate-only join record (for example a hand unmute in the rebuild + that adds a gain ride) aimed the column at the ride's value at 0 dB, no plan's value, and then + jumped to the curve. Now `FollowCursors::place_new_values` places such value cursors at the first + block's node time before the cell applies, and `place_carried` does not place them again (that + dropped the record's column-jump deferral, so the ride's grid cut into the unmute, R19-3). + Repositioned and continued cursors keep the value the column heard, as T3 specified. +- **R19-5 holds: exact, no flag.** Both moves (hand offset, D6 `matrix_lr`) with their own 4,000 + samples run to their end across a table-moving rebuild, `EXACT`, no transition fallback, on both + hosts. While writing the gate a first failure was the test's: an `UpsertAutomation` built from the + pre-move document re-authors the entry with offset 0, which is a real offset change (the join + writes a record); the gate re-authors from the committed model. + +Gates (all green), with red-without-the-feature evidence (once each): +- Gate 1: host-core `route_mute.rs` `a_flat_send_ride_renders_the_static_sends_bits` (Simd8 when + avx2, Simd4, scalar oracle; three live forms; offsets `+0.0`/`-0.0`; flat mute and `rr`; zero + render allocations); capi `a_flat_send_ride_renders_the_static_sends_bits_on_the_c_abi`; host-web + `a_flat_send_ride_renders_the_static_sends_bits_on_both_hosts`. Red without the seed fix: sample 0 + -0.13376695 != -0.11921991 (host-core), block 0 (capi). +- Gate 2: host-core `a_send_ride_is_heard_at_its_taps_time` (input and post-fader taps behind the + 486-sample limiter vs the twin with no latency, shifted by `L`, bit for bit; quanta 128 and 100; + three widths). Red with the route arrival forced to 0: post-fader sample 0 0.1787 != 0.2498. +- Gate 3: capi `send_row_edits_rebuild_or_commit_as_the_rows_rule_says_on_the_c_abi` ((a) rebuild + `EXACT`, fresh seeked plan after the jump; (b) `+3` live then the offset plan's bits; (c) automated + `matrix_rr` `model_only`, no bit; (d) un-automated `matrix_lr` live then a fresh plan's bits; + (e) gate 7 on the C ABI); host-web `send_row_edits_rebuild_or_commit_as_the_rows_rule_says_on_both_hosts`. + Red without the seed fix: (b) differs from the fresh plan after the ramp. The in-place/full + equivalence is T4's (`a_hand_move_on_an_automated_send_gain_*`). +- Gate 4: capi `a_hand_send_mute_ors_with_its_curve_on_the_c_abi`; host-web + `a_hand_send_mute_ors_with_its_curve_on_both_hosts`. Red with the curve replacing the static mute + (not ORed): "the hand mute shuts the send while the curve is open". +- Gate 5: capi `an_output_route_entry_is_refused_on_the_c_abi`; host-web + `an_output_route_entry_is_refused_alike_on_both_hosts` (one diagnostic, + `control.automation.target`). +- Gate 6: zero render allocations in host-core gates 1, 2 and 7 (`bench_support::alloc` thread + counters, the swap block included); control-plane `send_automation_realtime.rs` (T4). +- Gate 7: host-core `a_running_send_ride_across_an_unrelated_rebuild_is_the_never_swapped_twin` + (three widths); capi gate 3 (e); host-web + `send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts`. Red without the attach's + `with_continued_rows`: host-core sample 768 -0.053634815 != -0.053632714; host-web block 6; capi (e). +- Gate 8: the controls gates (T1, `the_send_gain_takes_one_shared_offset`), reused. +- R19-5: capi `a_live_send_move_in_flight_at_a_reposition_keeps_its_length_on_the_c_abi`; host-web + (in the gate 7 test); graph `a_hand_move_in_flight_at_a_swap_of_a_riding_table_is_the_twin_until_its_end`. +- T3 edge: graph `a_new_send_row_at_a_swap_composes_the_join_record_with_its_curve_at_the_first_block` + (red before the fix: target bits of `law(0 dB)`, then red with only the pre-placement: the grid + retargeted inside the unmute). + +Departures: gate 2 runs in host-core only (three widths); the hosts share that route op and its +arrival, and neither host fixture carries a latent insert. Host-web's gate 7 uses an added muted +(inactive) send as the unrelated rebuild, because a flat fader ride on `t3` is not bit-neutral at a +swap there (outside this issue; not investigated). `docs/SESSION_SCHEMA_V1.md`: send rows render, +the send gain takes the shared offset, routes into the output stay prepared. + +Realtime policy: 171 marked regions in 35 files (unchanged). `scripts/check-graph-policy.sh`: PASS. diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index dc4c51076..3d6f656ef 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -11704,3 +11704,453 @@ fn removing_a_sends_only_route_entry_reports_a_fallback() { "the rebuild's revision, outcome flags and transition-fallback count" ); } + +/// #1654 T5: an entry `name` on route `route`'s own row `row` (1 `gain_db`, 2 `mute`, 3-6 the +/// matrix) with `segments`. +fn route_entry( + name: &str, + route: &str, + row: u32, + segments: Vec, +) -> session::Automation { + session::Automation { + id: StableId::parse(name).expect("automation ID"), + target: session::ControlAddress { + owner: session::Owner::Route(StableId::parse(route).expect("route ID")), + section: session::Section::Own, + control: row, + lane: session::ParameterChannel::Both, + }, + segments, + offset: session::AutomationOffset::default(), + } +} + +/// #1654 T5: one segment of `shape` from `(start, from)` to `(end, to)` in `unit`. +fn send_segment( + shape: session::AutomationShape, + (start, from): (u64, f32), + (end, to): (u64, f32), + unit: session::ParameterUnit, +) -> session::AutomationSegment { + session::AutomationSegment { + shape, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit, + } +} + +/// #1654 T5: a flat curve at `value` over the source (step-shaped for a mute row). +fn send_flat( + value: f32, + unit: session::ParameterUnit, + step: bool, +) -> Vec { + let shape = if step { + session::AutomationShape::Step + } else { + session::AutomationShape::Linear + }; + vec![send_segment( + shape, + (0, value), + (SOURCE_FRAMES, value), + unit, + )] +} + +/// #1654 T5: the send `eq0-b`'s own row `name` (`gain_db`, `mute`, `matrix_lr`, ...) set to +/// `value`, over `ramp` samples when given. +fn send_row_edit(name: &str, value: f32, ramp: Option) -> SessionEdit { + use protocol::control_core::{Lane, Owner, Section, row_address}; + SessionEdit::set_control( + row_address( + Owner::Route(StableId::parse("eq0-b").expect("route ID")), + Section::Own, + name, + Lane::Both, + ) + .expect("a send row"), + value, + ramp, + ) +} + +/// #1654 T5: [`follow_document`]`(false, false)` with `entries` on its rows and `change` applied. +fn send_rows_document( + entries: Vec, + change: impl FnOnce(&mut SessionModel), +) -> String { + with_model(&follow_document(false, false), |model| { + model.automation.extend(entries); + change(model); + }) +} + +/// #1654 gate 1 on the C ABI. The send `eq0-b` (`pre_fader`, `rr` only) with a flat `gain_db` +/// ride at -6 dB (hand offset `+0.0`, then `-0.0`), a flat `rr` entry at 0.5 and a flat route +/// `mute` at 0 renders, block after block, the bits of the session whose send holds those values +/// statically. Not vacuous: a ride at -5 dB differs. +/// +/// Test value: red if the C ABI's route op reads the gain word as the compiler's linear static +/// gain rather than the seeded offset, or converts or composes the curves with other arithmetic +/// than the static route. +#[test] +fn a_flat_send_ride_renders_the_static_sends_bits_on_the_c_abi() { + use session::ParameterUnit::{Db, Linear}; + let fixed = send_rows_document(Vec::new(), |model| { + let send = model + .routes + .iter_mut() + .find(|route| route.id.as_str() == "eq0-b") + .expect("the send"); + send.gain_db = -6.0; + send.channel_matrix.rr = 0.5; + }); + let automated = |gain_db: f32, offset: f32| { + let mut gain = route_entry("send-gain", "eq0-b", 1, send_flat(gain_db, Db, false)); + gain.offset = session::AutomationOffset { + left_db: offset, + right_db: offset, + }; + send_rows_document( + vec![ + gain, + route_entry("send-mute", "eq0-b", 2, send_flat(0.0, Linear, true)), + route_entry("send-rr", "eq0-b", 6, send_flat(0.5, Linear, false)), + ], + |_| {}, + ) + }; + for offset in [0.0_f32, -0.0] { + let mut curve = Rig::new(&automated(-6.0, offset)); + let mut fixed = Rig::new(&fixed); + assert_eq!( + curve.current_follow_route_cells(), + vec![0], + "one seven-word send" + ); + for block in 0..24 { + assert_eq!( + bits(&curve.step()), + bits(&fixed.step()), + "offset {offset:?}, block {block}" + ); + } + } + let mut other = Rig::new(&automated(-5.0, 0.0)); + let mut fixed = Rig::new(&fixed); + assert!( + (0..8).any(|_| bits(&other.step()) != bits(&fixed.step())), + "a ride at -5 dB is heard" + ); +} + +/// #1654 gate 3 on the C ABI, with gate 7 (the unrelated rebuild). On [`send_ride_document`] +/// (`eq0-b` rides its gain from 0 to -12 dB) with an `rr` ride from 1 to 0.25: +/// (a) an `UpsertAutomation` that changes the gain ride rebuilds, completes `EXACT`, and from the +/// end of the moved row's jump (`SendGain`, 240 samples) and the grid ramp after it, renders a +/// fresh plan of the new document, session-seeked to the adoption block; +/// (b) `SET_CONTROL gain_db = +3` is `live` and, after its ramp, renders a fresh plan whose entry +/// carries the offset `+3`; +/// (c) `SET_CONTROL matrix_rr` (automated) is `model_only` and moves no bit; +/// (d) `SET_CONTROL matrix_lr = 0.5` (not automated) is `live` and, after its ramp, renders a fresh +/// plan with that value; +/// (e) a rebuild that changes only the direct route `eq0-main` (into the output) completes `EXACT` +/// and the send's plane is bit for bit the never-swapped twin's. +/// +/// Test value: red if a send automation edit is not a rebuild or does not complete exactly, if a +/// hand send gain move writes the fallback instead of the offset (or the offset reaches the +/// matrix), if a hand edit of an automated matrix row fights the curve, if an un-automated value +/// of the group is lost, or if a running ride is re-timed across an unrelated rebuild. +#[test] +fn send_row_edits_rebuild_or_commit_as_the_rows_rule_says_on_the_c_abi() { + use session::{AutomationShape::Linear, ParameterUnit}; + let rr_ride = || { + route_entry( + "send-rr", + "eq0-b", + 6, + vec![send_segment( + Linear, + (0, 1.0), + (SOURCE_FRAMES, 0.25), + ParameterUnit::Linear, + )], + ) + }; + let document = with_model(&send_ride_document(), |model| { + model.automation.push(rr_ride()); + }); + let quantum = 128_usize; + // (a) A changed gain ride. + { + let mut rig = Rig::new(&document); + for _ in 0..4 { + rig.step(); + } + let mut changed = parse_session_json(&document).expect("model").automation[0].clone(); + assert_eq!(changed.id.as_str(), "send-gain"); + changed.segments[0].end_value = -24.0; + let (adoption, live) = rebuild_and_adopt( + &mut rig, + &[SessionEdit::UpsertAutomation { + automation: changed, + }], + ADOPTED_BLOCKS, + ); + let reference = seeked_reference(&rig.snapshot(), adoption, ADOPTED_BLOCKS); + let settled = 240 + 2 * 64; + let send = plane_bits(&live, 1, quantum); + let fresh = plane_bits(&reference, 1, quantum); + assert_ne!(send[..64], fresh[..64], "(a): the moved row jumps"); + assert_eq!( + send[settled..], + fresh[settled..], + "(a): a fresh plan after the jump" + ); + } + // (b) and (d): live hand moves, against a fresh plan of the committed document. + for (label, edit) in [ + ("(b) the gain offset", send_row_edit("gain_db", 3.0, None)), + ( + "(d) an un-automated matrix_lr", + send_row_edit("matrix_lr", 0.5, None), + ), + ] { + let mut rig = Rig::new(&document); + for _ in 0..4 { + rig.step(); + } + let (at, live) = live_and_render(&mut rig, std::slice::from_ref(&edit), 12); + let committed = rig.snapshot(); + if label.starts_with("(b)") { + let model = parse_session_json(&committed).expect("model"); + assert_eq!( + model.automation[0].offset.left_db.to_bits(), + 3.0_f32.to_bits() + ); + } + let reference = seeked_reference(&committed, at, 12); + // The move's 240 samples, the deferred landing's 240 and the grid ramp after it. + let from = 5; + assert_ne!(live[0], reference[0], "{label}: the move ramps"); + 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" + ); + } + } + // (c) An automated matrix row: automation wins. + { + let mut rig = Rig::new(&document); + let mut twin = Rig::new(&document); + for _ in 0..4 { + assert_eq!(bits(&rig.step()), bits(&twin.step())); + } + let bytes = rig.transaction(&[send_row_edit("matrix_rr", 0.75, None)]); + let (result, response) = rig.send(&bytes); + assert_eq!(result, crate::RESULT_OK); + assert_eq!( + edit_path_of(&response), + protocol::EditPath::ModelOnly, + "(c)" + ); + for block in 4..16 { + assert_eq!(bits(&rig.step()), bits(&twin.step()), "(c): block {block}"); + } + } + // (e) An unrelated rebuild: the send's plane is the never-swapped twin's. + { + let mut rig = Rig::new(&document); + let mut twin = Rig::new(&document); + for _ in 0..4 { + assert_eq!(bits(&rig.step()), bits(&twin.step())); + } + let mut direct = parse_session_json(&document) + .expect("model") + .routes + .into_iter() + .find(|route| route.id.as_str() == "eq0-main") + .expect("the direct route"); + direct.gain_db = -3.0; + let (adoption, live) = rebuild_and_adopt( + &mut rig, + &[SessionEdit::UpsertRoute { route: direct }], + ADOPTED_BLOCKS, + ); + while (twin.block as usize) * quantum < adoption as usize { + twin.step(); + } + let reference: Vec<_> = (0..live.len()).map(|_| twin.step()).collect(); + assert_eq!( + plane_bits(&live, 1, quantum), + plane_bits(&reference, 1, quantum), + "(e): the send's plane across the swap" + ); + assert_ne!( + plane_bits(&live, 0, quantum), + plane_bits(&reference, 0, quantum), + "(e): the rebuild is heard on the strip's plane" + ); + } +} + +/// #1654 gate 4 on the C ABI (D5: a hand send mute ORs with the automated send mute). `eq0-b`'s +/// own mute curve is open until 2,048 and closed after. At block 4 a hand mute is `live` and, +/// after the mute ramp, shuts the send while the curve is open; at block 20 (2,560: the curve +/// closed) a hand unmute is `live` and the send stays shut. +/// +/// Test value: red if the composition is not an OR (the hand mute does not close an open curve, +/// or the hand unmute reopens a closed one). +#[test] +fn a_hand_send_mute_ors_with_its_curve_on_the_c_abi() { + use session::{AutomationShape::Step, ParameterUnit::Linear}; + let document = send_rows_document( + vec![route_entry( + "send-mute", + "eq0-b", + 2, + vec![ + send_segment(Step, (0, 0.0), (2_048, 1.0), Linear), + send_segment(Step, (2_048, 1.0), (SOURCE_FRAMES, 1.0), Linear), + ], + )], + |_| {}, + ); + let quantum = 128_usize; + let mut rig = Rig::new(&document); + for _ in 0..4 { + rig.step(); + } + let (_, muted) = live_and_render(&mut rig, &[send_row_edit("mute", 1.0, None)], 16); + let send = plane_bits(&muted, 1, quantum); + assert!( + send[..64].iter().any(|&word| word != 0), + "the send plays at the edit" + ); + assert!( + send[480..].iter().all(|&word| f32::from_bits(word) == 0.0), + "the hand mute shuts the send while the curve is open" + ); + let (_, unmuted) = live_and_render(&mut rig, &[send_row_edit("mute", 0.0, None)], 12); + assert!( + plane_bits(&unmuted, 1, quantum) + .iter() + .all(|&word| f32::from_bits(word) == 0.0), + "the hand unmute leaves the closed curve closed" + ); +} + +/// #1654 gate 5 on the C ABI: an automation entry on the route into the output `eq0-main` is +/// refused `control.automation.target`, and the revision does not move. +/// +/// Test value: red if a route into the output's rows became automatable (D8). +#[test] +fn an_output_route_entry_is_refused_on_the_c_abi() { + let mut rig = Rig::new(&follow_document(false, false)); + rig.step(); + let before = rig.summary(); + let entry = route_entry( + "out-gain", + "eq0-main", + 1, + send_flat(-6.0, session::ParameterUnit::Db, false), + ); + assert_ne!( + rig.apply(&[SessionEdit::UpsertAutomation { automation: entry }]), + crate::RESULT_OK + ); + assert!( + String::from_utf8_lossy(&rig.last_error()).contains("control.automation.target"), + "{}", + String::from_utf8_lossy(&rig.last_error()) + ); + assert_eq!(rig.summary(), before, "no revision"); +} + +/// #1654 R19-5 on the C ABI. On [`send_ride_document`], a live send move with its own 4,000 +/// samples (the hand gain offset `+3`, or a D6 `matrix_lr` edit) is in flight when a rebuild +/// changes the gain ride's table (the carried cursor is repositioned). The rebuild completes +/// `EXACT`, with no transition fallback; until the move's end every sample is a twin's that took +/// the same move and no rebuild (the move keeps its length); after the move, the moved row's +/// jump and the grid ramp after it, the plan renders a fresh plan of the committed document. +/// +/// Test value: red if a reposition at the swap cuts the live send move (lands the moved table +/// at once) or re-times it, or if the rebuild is flagged. +#[test] +fn a_live_send_move_in_flight_at_a_reposition_keeps_its_length_on_the_c_abi() { + const RAMP: u32 = 4_000; + let document = send_ride_document(); + let quantum = 128_usize; + for (label, edit) in [ + ("the gain offset", send_row_edit("gain_db", 3.0, Some(RAMP))), + ("matrix_lr", send_row_edit("matrix_lr", 0.5, Some(RAMP))), + ] { + let mut rig = Rig::new(&document); + let mut twin = Rig::new(&document); + for _ in 0..4 { + assert_eq!(bits(&rig.step()), bits(&twin.step())); + } + let (start, _) = live_and_render(&mut rig, std::slice::from_ref(&edit), 2); + let _ = live_and_render(&mut twin, std::slice::from_ref(&edit), 2); + // The re-authored ride keeps the entry's committed offset (R17-1), so the join writes + // no record and the move is the only ramp in flight. + let mut changed = rig.model().automation[0].clone(); + assert_eq!(changed.id.as_str(), "send-gain"); + changed.segments[0].end_value = -24.0; + let rebuild = SessionEdit::UpsertAutomation { + automation: changed, + }; + let writes = rig.current_follow_route_cells(); + let (adoption, live) = rebuild_and_adopt(&mut rig, std::slice::from_ref(&rebuild), 48); + assert_eq!( + rig.current_follow_route_cells(), + vec![0], + "{label}: the join writes no record ({writes:?} before)" + ); + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + assert_eq!( + watermark.transition_fallback_count, 0, + "{label}: no transition fallback" + ); + while (twin.block as usize) * quantum < adoption as usize { + twin.step(); + } + let reference: Vec<_> = (0..live.len()).map(|_| twin.step()).collect(); + let end = (start + u64::from(RAMP) - adoption) as usize; + for plane in 0..2 { + let (live, twin) = ( + plane_bits(&live, plane, quantum), + plane_bits(&reference, plane, quantum), + ); + assert_eq!( + live[..end], + twin[..end], + "{label}, plane {plane}: the move runs" + ); + } + let fresh = seeked_reference(&rig.snapshot(), adoption, 48); + let settled = end + 240 + 2 * 64; + let (send, fresh) = ( + plane_bits(&live, 1, quantum), + plane_bits(&fresh, 1, quantum), + ); + assert_ne!( + send[end..settled], + fresh[end..settled], + "{label}: the moved row lands" + ); + assert_eq!( + send[settled..], + fresh[settled..], + "{label}: a fresh plan after the landing" + ); + } +} diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 778208934..13f2e9394 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -5166,12 +5166,6 @@ pub struct GraphRouteMove { /// 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, - /// Per route row 1 to 6 ([`RouteFollowFlags::route_rows`]'s order), whether both plans' - /// models automate the row with the same segment table (#1654 D3, - /// `controls::continued_route_rows`): such a row's cell carries its cell state and heard value - /// verbatim, by `(route ID, row)`; another row is repositioned. All `false` for a route - /// neither plan gives a route row cell. - pub continued_rows: [bool; GRAPH_ROUTE_ROWS], } /// The strip delay lines of a bound graph plan, in line order (#1284 D1); `None` for any other diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index 7f7036009..2a9a1f29a 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -1670,11 +1670,19 @@ impl LiveFollowRoute { /// them with the seeded 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 target mixes. /// Control side: it allocates the cursor tables. + /// + /// When the plan automates the gain (flags bit 6), the cell's `gain` word is the hand offset + /// in dB, not a linear gain: the bound word is the source's seeded offset + /// ([`crate::GraphFollowRouteSource::gain_offset`]), and `transform.gain` (the compiler's + /// static linear gain, a fallback render never hears) is replaced by it (#1654 T4 note). pub(crate) fn new( - transform: crate::RouteTransform, + mut transform: crate::RouteTransform, control: Box, source: &crate::GraphFollowRouteSource, ) -> Self { + if source.flags.automated_gain { + transform.gain = source.gain_offset; + } let mut follow = FollowCursors { program: std::sync::Arc::clone(&source.program), columns: [0, 1].map(|column| RouteColumn::bind(source, column)), @@ -1976,6 +1984,41 @@ fn next_column_event( /// Call `each` on every value cursor of `walk` that a carried first block placed (it does not /// continue its predecessor's table). +impl FollowCursors { + /// A carried route's first block, before its cell (#1654 T5): each value cursor of a row + /// the predecessor did not automate ([`Placement::New`]) hears its curve at node time `at` + /// and is placed there, so a join record composes with the value a fresh plan hears at `at`, + /// not with the cursor's bind seed (the curve at node time 0), which no plan heard. The + /// predecessor heard the row's static value, which the successor's composition cannot + /// express; a repositioned or continued cursor keeps the value the column heard. + /// [`LiveFollowRoute::place_carried`] leaves it placed, so a join record's column jump keeps + /// its deferral (R19-3). + fn place_new_values(&mut self, at: i64) { + let Self { + program, columns, .. + } = self; + for walk in columns.iter_mut().flatten() { + for role in 0..ROLES { + let new = walk.placement.get(role) == Some(&Placement::New); + let (Some(index), true) = (walk.roles.of(role), new) else { + continue; + }; + let Some(cursor) = walk.walk.cursor_mut(index) else { + continue; + }; + if cursor.kind() != automation::CursorKind::Value { + continue; + } + let Some(cell) = program.cell(cursor.cell() as usize) else { + continue; + }; + cursor.state_mut().reposition(&cell, at); + cursor.set_heard(automation::value_at(cell.segments(), at)); + } + } + } +} + fn for_placed_values(walk: &mut RouteColumn, mut each: impl FnMut(&mut automation::ComposeCursor)) { for role in 0..ROLES { let placed = walk @@ -2167,6 +2210,13 @@ impl LiveFollowRoute { let Some(cursor) = walk.walk.cursor_mut(index) else { continue; }; + // A new value cursor was placed before the cell (`place_new_values`); placing it + // again would drop the deferral a join record's column jump gave it. + if walk.placement.get(role) == Some(&Placement::New) + && cursor.kind() == automation::CursorKind::Value + { + continue; + } let Some(cell) = follow.program.cell(cursor.cell() as usize) else { continue; }; @@ -2228,10 +2278,15 @@ impl LiveFollowRoute { ) -> Option { let (block, start) = self.follow.block(time); self.block = block; + let carried = match (start, block.start) { + (FollowStart::Carried, Some(at)) => Some(at), + _ => None, + }; + if let Some(at) = carried { + self.follow.place_new_values(at); + } self.apply_cell(snapshot, block.start); - if start == FollowStart::Carried - && let Some(at) = block.start - { + if let Some(at) = carried { self.place_carried(at); } if !(self.mute && self.ramps.is_settled() && !self.delayed) { @@ -19531,6 +19586,13 @@ mod send_automation_tests { ) .expect("source") .with_continued_rows(continued); + // The cell's `gain` word of an automated gain is the hand offset: the harness's + // `transform.gain` seeds it. + let source = if gain { + source.with_gain_offset(transform.gain).expect("offset") + } else { + source + }; let (binding, producer) = crate::follow_route_control_pair( &StableGraphId::parse("send").expect("ID"), &LiveValuesSuperseded::new(), @@ -20056,4 +20118,98 @@ mod send_automation_tests { assert_eq!(successor.route.ramps.positions[column], 48); } } + + /// R19-5 with riding tables: a hand offset record in flight at a swap whose gain ride's table + /// moves keeps its own length: until its end every block is the never-swapped twin's. + /// + /// Red if the repositioned cursor lands the moved table inside the hand move. + #[test] + fn a_hand_move_in_flight_at_a_swap_of_a_riding_table_is_the_twin_until_its_end() { + let ride = |end_db: f32| Curves { + gain: Some(vec![seg(0, 12_288, 0.0, end_db, Shape::Linear)]), + ..Curves::default() + }; + let moved = RouteTransform { gain: 3.0, ..OPEN }; + let mut twin = Send::new(&ride(-12.0), OPEN); + let mut predecessor = Send::new(&ride(-12.0), OPEN); + for index in 0..4_u64 { + assert_eq!(twin.run(index * 64), predecessor.run(index * 64)); + } + twin.write(moved, 4_000); + predecessor.write(moved, 4_000); + for index in 4..6_u64 { + assert_eq!(twin.run(index * 64), predecessor.run(index * 64)); + } + let mut successor = Send::new(&ride(-24.0), moved); + swap(&mut successor, &mut predecessor); + // The move started at 256 and ends at 4,256: block 66 of 64 holds its end. + for index in 6..66_u64 { + assert_eq!( + successor.run(index * 64), + twin.run(index * 64), + "block {index}" + ); + } + } + + /// T5, a row the predecessor did not automate: a send muted by hand whose successor gains a + /// gain ride and is unmuted in the same rebuild (a gate-only join record on the carried first + /// block) aims its unmute at the ride's value at the first block's node time, the value a + /// fresh plan hears there, not at the ride's value at node time 0 (the new cursor's bind + /// seed), which no plan hears. + /// + /// Red if the record composes with the new cursor's bind seed (the unmute ramps to the gain + /// at 0 dB and only then jumps to the ride), or if the carry's placement drops the column + /// jump's hold, so the ride's grid retargets inside the unmute (R19-3). + #[test] + fn a_new_send_row_at_a_swap_composes_the_join_record_with_its_curve_at_the_first_block() { + let before = Curves { + mute: Some(flat(0.0)), + ..Curves::default() + }; + let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let after = Curves { + gain: Some(ride.clone()), + mute: Some(flat(0.0)), + ..Curves::default() + }; + let mut predecessor = Send::new(&before, OPEN); + let muted = RouteFollowFlags { + mute: true, + ..predecessor.route.flags + }; + predecessor.revision += 1; + let record = FollowRouteControlRecord::new(OPEN, muted, 0).expect("record"); + predecessor.producer.write( + predecessor.pin.stamp(predecessor.revision).expect("stamp"), + record, + ); + predecessor + .pin + .publish(&predecessor.gate, predecessor.revision); + for index in 0..40_u64 { + predecessor.run(index * 64); + } + assert_eq!(predecessor.route.ramps.target().map(f32::to_bits), [0; 4]); + let mut successor = Send::new(&after, OPEN); + swap(&mut successor, &mut predecessor); + let flags = successor.route.flags; + successor.revision += 1; + let record = FollowRouteControlRecord::new(OPEN, flags, 200).expect("record"); + successor.producer.write( + successor.pin.stamp(successor.revision).expect("stamp"), + record, + ); + successor.pin.publish(&successor.gate, successor.revision); + let expected = open(OPEN, law(automation::value_at(&ride, 40 * 64), 0.0)); + // The unmute's column jump runs to 2,760: the ride's grid is held through block 41. + for index in 40..42_u64 { + successor.run(index * 64); + assert_eq!( + successor.route.ramps.target().map(f32::to_bits), + expected.map(f32::to_bits), + "block {index}" + ); + } + } } diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index e2f9cd675..0a975ead3 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -2922,7 +2922,15 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( } let flags = follow_route_flags(route, effective.strips[source].mute, automated); let offset = route_gain_offset(controls::ModelView::whole(model), &route.id); - follow_cells.push((route.id.as_str(), (program, cells, rows, flags, offset))); + // #1654 D3: the rows whose table the predecessor's model holds unchanged carry their + // cursors verbatim at the swap (the source's continued rows, which the route op reads). + let continued = successor.map_or([false; graph::GRAPH_ROUTE_ROWS], |base| { + controls::continued_route_rows(base.committed, model, &route.id) + }); + follow_cells.push(( + route.id.as_str(), + (program, cells, rows, flags, offset, continued), + )); } let mut artifact = GraphCompiler::compile_with_builtins(GraphBuiltinsCompileRequest { dispatch: backend, @@ -2977,7 +2985,8 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( let mut sources: BTreeMap<&str, _> = follow_cells.into_iter().collect(); artifact .attach_route_live_controls_following(&live_superseded, six_word, |route_id| { - let (program, cells, rows, flags, offset) = sources.remove(route_id.as_str())?; + let (program, cells, rows, flags, offset, continued) = + sources.remove(route_id.as_str())?; let node = GraphNodeId::Route { route_id: route_id.clone(), }; @@ -2989,7 +2998,8 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( follow_route_jumps, graph_compiler::route_gain_law, flags, - )?; + )? + .with_continued_rows(continued); if flags.automated_gain { source.with_gain_offset(offset) } else { @@ -5898,9 +5908,6 @@ fn resolve_strip_delay_carry( struct RouteJoin<'a> { id: &'a str, retarget: Option, - /// The route rows 1-6 both models automate with the same table (#1654 D3, - /// [`controls::continued_route_rows`]): the move's [`graph::GraphRouteMove::continued_rows`]. - continued_rows: [bool; graph::GRAPH_ROUTE_ROWS], } /// The model half of the send join (#1284 D2, D3): for every route of `model` into a submix that @@ -5986,7 +5993,6 @@ fn join_routes<'a>( joins.push(RouteJoin { id: after.id.as_str(), retarget, - continued_rows: controls::continued_route_rows(committed, model, &after.id), }); } joins @@ -6034,7 +6040,6 @@ fn resolve_route_carry<'a>( successor: after, predecessor: before, predecessor_follows: follows, - continued_rows: join.continued_rows, }); if let Some(record) = join.retarget { carry.retargets.push((join.id, record)); @@ -6791,25 +6796,20 @@ mod tests { RouteJoin { id: "a-main", retarget: Some(record), - continued_rows: [false; graph::GRAPH_ROUTE_ROWS], }, - // #1654 D3: the join's continued rows reach the move as they are. RouteJoin { id: "b-main", retarget: None, - continued_rows: [true, false, false, false, false, true], }, // Present in the successor only. RouteJoin { id: "c-main", retarget: Some(record), - continued_rows: [false; graph::GRAPH_ROUTE_ROWS], }, // Present in the predecessor's model but not a live route of either plan. RouteJoin { id: "d-main", retarget: Some(record), - continued_rows: [false; graph::GRAPH_ROUTE_ROWS], }, ]; let carry = resolve_route_carry(&before, &own, &joins); @@ -6820,13 +6820,11 @@ mod tests { successor: 4, predecessor: 3, predecessor_follows: false, - continued_rows: [false; graph::GRAPH_ROUTE_ROWS], }, graph::GraphRouteMove { successor: 2, predecessor: 5, predecessor_follows: false, - continued_rows: [true, false, false, false, false, true], }, ] ); @@ -6853,11 +6851,7 @@ mod tests { unit, following, }; - let join = |id| RouteJoin { - id, - retarget: None, - continued_rows: [false; graph::GRAPH_ROUTE_ROWS], - }; + let join = |id| RouteJoin { id, retarget: None }; // (case, predecessor following, successor following, flagged) for (case, before, after, flagged) in [ ("six -> six", false, false, false), diff --git a/crates/host-core/tests/route_mute.rs b/crates/host-core/tests/route_mute.rs index bec298485..34e687f74 100644 --- a/crates/host-core/tests/route_mute.rs +++ b/crates/host-core/tests/route_mute.rs @@ -2529,3 +2529,252 @@ fn the_following_route_charge_covers_its_attach_and_bind() { resources.total_bytes ); } + +/// #1654 T5: stored send-row automation (route rows 1-6) on the send `t-b` of [`follow::model`]. +#[cfg(feature = "test-support")] +mod send_rows { + use super::sid; + use session::{ + Automation, AutomationOffset, AutomationSegment, AutomationShape, ControlAddress, Owner, + ParameterChannel, ParameterUnit, Section, + }; + + /// An entry `name` on `t-b`'s route row `row` (1 `gain_db`, 2 `mute`, 3-6 the matrix). + pub(super) fn entry(name: &str, row: u32, segments: Vec) -> Automation { + Automation { + id: sid(name), + target: ControlAddress { + owner: Owner::Route(sid("t-b")), + section: Section::Own, + control: row, + lane: ParameterChannel::Both, + }, + segments, + offset: AutomationOffset::default(), + } + } + + /// One segment of `shape` from `(start, from)` to `(end, to)` in `unit`. + pub(super) fn segment( + shape: AutomationShape, + (start, from): (u64, f32), + (end, to): (u64, f32), + unit: ParameterUnit, + ) -> AutomationSegment { + AutomationSegment { + shape, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit, + } + } + + /// A flat curve at `value` in `unit`. + pub(super) fn flat(value: f32, unit: ParameterUnit) -> Vec { + vec![segment( + AutomationShape::Linear, + (0, value), + (1 << 20, value), + unit, + )] + } + + /// A linear gain ride from 0 dB at 0 to `end_db` at `end`. + pub(super) fn gain_ride(end: u64, end_db: f32) -> Vec { + vec![segment( + AutomationShape::Linear, + (0, 0.0), + (end, end_db), + ParameterUnit::Db, + )] + } +} + +/// #1654 gate 1 (host-core form, every bank width and the scalar oracle, every live form). The +/// send `t-b` (`pre_fader`, `rr` only) with a flat `gain_db` ride at -6 dB (hand offset `+0.0`, +/// then `-0.0` on both fields), a flat `rr` entry at 0.5 and a flat route `mute` at 0 renders, bit +/// for bit, the session whose send holds those values statically, and render allocates nothing +/// after the first block. Not vacuous: a flat ride at -5 dB differs. +/// +/// Test value: red if the route op reads the gain word as the compiler's linear static gain +/// rather than the seeded offset (the T4 note: `law(-6, 0.5)` is heard), converts the curve with +/// other arithmetic than the static gain's law, hears an automated matrix entry's fallback, or +/// retargets a flat ride. +#[cfg(feature = "test-support")] +#[test] +fn a_flat_send_ride_renders_the_static_sends_bits() { + use follow::*; + use send_rows::{entry, flat}; + use session::ParameterUnit; + const FRAMES: usize = 3_000; + let pcm = feed(FRAMES + 256, None); + let base = model(128, SendTap::PreFader, false, true); + let mut fixed = base.clone(); + fixed.routes[1].gain_db = -6.0; + fixed.routes[1].channel_matrix.rr = 0.5; + let automated = |gain_db: f32, offset: f32| { + let mut model = base.clone(); + let mut gain = entry("send-gain", 1, flat(gain_db, ParameterUnit::Db)); + gain.offset = session::AutomationOffset { + left_db: offset, + right_db: offset, + }; + model.automation = vec![ + gain, + entry( + "send-mute", + 2, + vec![send_rows::segment( + session::AutomationShape::Step, + (0, 0.0), + (1 << 20, 0.0), + ParameterUnit::Linear, + )], + ), + entry("send-rr", 6, flat(0.5, ParameterUnit::Linear)), + ]; + model + }; + for backend in follow::backends() { + for live in LIVE_FORMS { + let what = format!("{backend:?} {live:?}"); + let fixed = run(&fixed, backend, live, &pcm, FRAMES); + for offset in [0.0_f32, -0.0] { + let curve = run(&automated(-6.0, offset), 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"); + for plane in 0..2 { + assert_bits_equal( + &curve.out[plane], + &fixed.out[plane], + &format!("{what}, offset {offset:?}: plane {plane}"), + ); + } + } + let other = run(&automated(-5.0, 0.0), backend, live, &pcm, FRAMES); + assert!( + first_difference_from(&other.out[1], &fixed.out[1], 0).is_some(), + "{what}: a ride at -5 dB is heard" + ); + } + } +} + +/// #1654 gate 2 (every bank width and the scalar oracle, quanta 128 and 100). The send `t-b` +/// rides its gain linearly from 0 to -24 dB over 4,000 samples; track `t` carries a latent +/// insert of `L` samples before its fader. With an `input` tap the send reads `t` `L` render +/// samples before the strip's fader and PDC delays the bus path by `L` at the output; with a +/// `post_fader` tap it reads at the fader's arrival, `L`. In both cases the send's plane (the +/// output's right plane) is, from sample `L` on, the plane of a twin with no latent insert shifted +/// by `L`, bit for bit: each grid value of the ride reaches the bus on the timeline sample the +/// twin's does, and the post-fader ride's ramps start on the strip's render samples. +/// +/// Test value: red if the send's events use the fader's arrival instead of the tap's (an `input` +/// tap would ride `L` samples late), the route's own arrival, or the render sample. +#[cfg(feature = "test-support")] +#[test] +fn a_send_ride_is_heard_at_its_taps_time() { + use follow::*; + use send_rows::{entry, gain_ride}; + const FRAMES: usize = 4_000; + let pcm = feed(FRAMES + 1_024, Some(0.25)); + for quantum in [128_u32, 100] { + for tap in [SendTap::Input, SendTap::PostFader] { + let with_ride = |latent: bool| { + let mut model = model(quantum, tap, latent, true); + model.automation = vec![entry("send-gain", 1, gain_ride(4_000, -24.0))]; + model + }; + let (latent, twin) = (with_ride(true), with_ride(false)); + for backend in follow::backends() { + let what = format!("q={quantum} {tap:?} {backend:?}"); + let late = run(&latent, backend, Live::None, &pcm, FRAMES); + let early = run(&twin, backend, Live::None, &pcm, FRAMES); + assert_eq!(late.allocations, (0, 0, 0), "{what}: render allocates"); + let shift = L as usize; + assert_bits_equal( + &late.out[1][shift..], + &early.out[1][..FRAMES - shift], + &format!("{what}: the send plane, shifted by the latency"), + ); + // Not vacuous: the ride moves over the compared window. + assert_ne!( + early.out[1][100].to_bits(), + early.out[1][3_000].to_bits(), + "{what}: the send rides" + ); + } + } + } +} + +/// #1654 gate 7 (every bank width and the scalar oracle). The send `t-b` rides its gain linearly +/// from 0 to -24 dB over 4,000 samples and its `ll` entry from 0 to 0.5. A rebuild at block 6 +/// that changes only the direct route `t-main`'s gain (a route into the output: a rebuild row) +/// keeps both tables, so the send's cursors carry verbatim: the send's plane (the output's right +/// plane) is bit for bit the never-swapped predecessor's in every block, and the swap allocates +/// and frees nothing. Not vacuous: the strip's plane moves with the rebuild, and the ride is +/// still moving at the swap. +/// +/// Test value: red if the plan does not hand the route op the rows whose tables both models hold +/// (`controls::continued_route_rows` through `GraphFollowRouteSource::with_continued_rows`): a +/// repositioned cursor re-times the running ride. +#[cfg(feature = "test-support")] +#[test] +fn a_running_send_ride_across_an_unrelated_rebuild_is_the_never_swapped_twin() { + use follow::*; + use send_rows::{entry, gain_ride, segment}; + use session::{AutomationShape, ParameterUnit}; + use successor::{QUANTUM, SWAP_BLOCK}; + let pcm = successor::Feed::new("t", 23); + let mut a = model(QUANTUM as u32, SendTap::PreFader, false, true); + a.automation = vec![ + entry("send-gain", 1, gain_ride(4_000, -24.0)), + entry( + "send-ll", + 3, + vec![segment( + AutomationShape::Linear, + (0, 0.0), + (4_000, 0.5), + ParameterUnit::Linear, + )], + ), + ]; + let mut b = a.clone(); + b.routes[0].gain_db = -3.0; + assert_eq!(b.routes[0].id.as_str(), "t-main"); + let quiet = |_: usize, + _: &mut host_core::HostLiveControlHandles, + _: &mut engine::realtime::PlanRevisionGate| {}; + for backend in follow::backends() { + let what = format!("{backend:?}"); + let (twin, _) = live_swap(&a, None, backend, &pcm, quiet); + let (swapped, handles) = live_swap(&a, Some((&b, &a)), backend, &pcm, quiet); + assert_eq!( + handles + .expect("the successor's handles") + .follow_route_controls + .len(), + 1, + "{what}: the send is a following route" + ); + assert_quiet(&swapped, &what); + let (send, twin_send) = (plane(&swapped, 1), plane(&twin, 1)); + assert_bits_equal(&send, &twin_send, &format!("{what}: the send's plane")); + let at = SWAP_BLOCK * QUANTUM; + assert!( + first_difference_from(&plane(&swapped, 0), &plane(&twin, 0), at).is_some(), + "{what}: the rebuild is heard on the strip's plane" + ); + assert_ne!( + twin_send[at].to_bits(), + twin_send[at + QUANTUM].to_bits(), + "{what}: the ride moves at the swap" + ); + } +} diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index 94545ac21..536513af0 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 and mute rows render; every other row is inert today.** An entry on a strip's fader +**The fader, mute and send 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 @@ -306,7 +306,19 @@ curve and a hand unmute leaves a closed curve closed. Only a change of that OR m 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, +unchanged finishes. An entry on a send's own rows (#1654: a route into a submix, rows 1 `gain_db`, +2 `mute` and 3-6 `matrix_ll`, `matrix_lr`, `matrix_rl`, `matrix_rr`) is compiled into the same table +after every mute entry and rendered on the C ABI and in the browser alike: the send follows its +curves in the node time of its tap (`input`, `pre_fader`, ...), so a pre-insert send behind a latent +insert moves on the timeline sample of the audio it carries. The gain and matrix rows take the +fader's grid (a 64-sample retarget toward the curve's value at each grid end) and jump over the +session's send gain and send matrix ramp lengths (`fader_ms`, `pan_ms`); the send `mute` row is +`step` only and moves over the mute ramp length. A flat ride renders the bits of the same static +send. The send's mute is the curve's OR its static (hand) `mute`, as the strip's is; a hand edit of +an automated matrix row is stored and commits `model_only` (automation wins), and a live edit of a +send row the session does not automate stays live. A send's `follows_mute` (row 7) is not +automatable (#1632). A route into the output stays prepared: its rows are rebuild-only, so an entry +on one is refused `control.automation.target`. 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 @@ -336,8 +348,11 @@ fields 1 and 2. A missing record or lane is `schema.missing_field` and a third k `schema.unknown_field`, at their paths. The row rule (`controls::automation_diagnostics`) judges each lane at `$.automation[id=].offset.left_db` or `.right_db`, after the segments: -- Only `fader_db` (builtins control 5 of a strip) takes an offset (`takes_offset`). The input trim - and the send gain do not yet: the slices that render them (#1658, #1654) add them. +- `fader_db` (builtins control 5 of a strip) and a send's `gain_db` (own control 1 of a route into + a submix, #1654) take an offset (`takes_offset`). The input trim does not yet: the slice that + renders it (#1658) adds it. The send gain row is shared, so its entry holds one offset: `right_db` + must have the bits of `left_db` (`control.automation.offset` at `.offset.right_db` otherwise, + `+0.0` with `-0.0` included), in the route gain domain `[-144, 24]` dB. - A lane that takes no offset holds exactly `+0.0`, else `control.automation.offset`. That is a lane the entry does not drive (`right_db` of a `left` entry, `left_db` of a `right` entry) and every lane of an entry whose row takes no offset. `-0.0` is refused there too, so "no offset" @@ -358,7 +373,11 @@ ID. On a lane a rendered `fader_db` entry drives, the `fader_db` row reads and w not a control value: the static fallback changes only through a rebuild (remove the automation, set the fader, upsert the automation, in one transaction) or a document replacement, and any edit that changes it on an automated lane is a change of structure. An `UpsertAutomation` that changes -only an entry's offset is live, as a hand move is. +only an entry's offset is live, as a hand move is. A send's automated `gain_db` takes its offset +the same way (#1654): render hears `db_to_gain(clamp(f64(curve) + f64(offset), -144, 24))`, rounded once +to `f32` before the conversion; `SET_CONTROL gain_db` on that send writes the entry's offset (both +fields, live), the control list reports the offset as the row's value and marks the row +`automated`, and the static `gain_db` is the fallback. **The input filter order, on curves (#1645).** On each strip lane where HPF (3) or LPF (4) is automated (a `both` entry drives both lanes), each side is its entry under the hold rule, or the diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 78a907b4d..12e6a7df4 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -6477,3 +6477,453 @@ fn a_hand_move_on_an_automated_send_gain_is_an_offset_alike_on_both_hosts() { assert_eq!(full.1, in_place.1, "one document"); assert_eq!(full.2, in_place.2, "the same bits on both paths"); } + +/// #1654 T5: an entry `name` on `t1-b`'s own row `row` (1 `gain_db`, 2 `mute`, 3-6 the matrix). +#[cfg(feature = "test-support")] +fn send_entry( + name: &str, + row: u32, + segments: Vec, +) -> session::Automation { + session::Automation { + id: id(name), + target: address( + Owner::Route(id("t1-b")), + Section::Own, + row, + ParameterChannel::Both, + ), + segments, + offset: session::AutomationOffset::default(), + } +} + +/// #1654 T5: one segment of `shape` from `(start, from)` to `(end, to)` in `unit`. +#[cfg(feature = "test-support")] +fn send_piece( + shape: session::AutomationShape, + (start, from): (u64, f32), + (end, to): (u64, f32), + unit: session::ParameterUnit, +) -> session::AutomationSegment { + session::AutomationSegment { + shape, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit, + } +} + +/// #1654 T5: `t1-b`'s own row `control` set to `value`, over `ramp` samples when given. +#[cfg(feature = "test-support")] +fn send_edit(control: u32, value: f32, ramp: Option) -> Vec { + vec![SessionEdit::set_control( + address(Owner::Route(id("t1-b")), Section::Own, control, Lane::Both), + value, + ramp, + )] +} + +/// #1654 T5: the send `t1-b` of `model`. +#[cfg(feature = "test-support")] +fn t1_b(model: &mut SessionModel) -> &mut session::Route { + model + .routes + .iter_mut() + .find(|route| route.id.as_str() == "t1-b") + .expect("the send") +} + +/// #1654 gate 1 on both hosts. `t1-b` with a flat `gain_db` ride at -6 dB (hand offset `+0.0`, +/// then `-0.0`), a flat `rr` entry at 0.5 and a flat route `mute` at 0 renders, on each host and +/// alike, the bits of the session whose send holds those values statically. Not vacuous: a ride +/// at -5 dB differs. +/// +/// Test value: red if either host's route op reads the gain word as the compiler's linear static +/// gain rather than the seeded offset, or composes the curves with other arithmetic than the +/// static route, or if the hosts disagree. +#[cfg(feature = "test-support")] +#[test] +fn a_flat_send_ride_renders_the_static_sends_bits_on_both_hosts() { + use session::{AutomationShape, ParameterUnit}; + const RUN: u64 = 24; + let end = u64::from(QUANTUM) * BLOCKS; + let flat = |value: f32, shape, unit| vec![send_piece(shape, (0, value), (end, value), unit)]; + let automated = |gain_db: f32, offset: f32| { + let mut model = follow_model(false, false); + let mut gain = send_entry( + "t1-b-gain", + 1, + flat(gain_db, AutomationShape::Linear, ParameterUnit::Db), + ); + gain.offset = session::AutomationOffset { + left_db: offset, + right_db: offset, + }; + model.automation.extend([ + gain, + send_entry( + "t1-b-mute", + 2, + flat(0.0, AutomationShape::Step, ParameterUnit::Linear), + ), + send_entry( + "t1-b-rr", + 6, + flat(0.5, AutomationShape::Linear, ParameterUnit::Linear), + ), + ]); + model + }; + let mut fixed = follow_model(false, false); + t1_b(&mut fixed).gain_db = -6.0; + t1_b(&mut fixed).channel_matrix.rr = 0.5; + let fixed = both_run(&fixed, RUN, &[]); + for offset in [0.0_f32, -0.0] { + assert_eq!( + both_run(&automated(-6.0, offset), RUN, &[]), + fixed, + "offset {offset:?}: the static send's bits" + ); + } + assert_ne!( + both_run(&automated(-5.0, 0.0), RUN, &[]), + fixed, + "a ride at -5 dB is heard" + ); +} + +/// #1654 gate 3 on both hosts. On [`send_ride_model`] (`t1-b` rides its gain from 0 to -12 dB) +/// with an `rr` ride from 1 to 0.25, each host gives one verdict and reply and renders the same +/// bits: (a) an `UpsertAutomation` that changes the gain ride rebuilds and completes `EXACT`, and +/// once the moved row has landed renders a fresh plan of the new model; (b) `SET_CONTROL gain_db = +/// +3` is `live` and, after its ramp and the deferred landing, renders a fresh plan whose entry +/// carries the offset `+3`; (c) `SET_CONTROL matrix_rr` (automated) is `model_only` and moves no +/// bit; (d) `SET_CONTROL matrix_lr = 0.5` (not automated) is `live` and, after its ramp, renders a +/// fresh plan with that value. +/// +/// Test value: red if a send automation edit is not an exact rebuild on either host, a hand send +/// gain move writes the fallback (or reaches the matrix), a hand edit of an automated matrix row +/// fights the curve, an un-automated value of the group is lost, or the hosts disagree. +#[cfg(feature = "test-support")] +#[test] +fn send_row_edits_rebuild_or_commit_as_the_rows_rule_says_on_both_hosts() { + use session::{AutomationShape, ParameterUnit}; + const RUN: u64 = 24; + const EDIT: u64 = 4; + const SETTLED: usize = 10; + let end = u64::from(QUANTUM) * BLOCKS; + let with_rr = |change: &dyn Fn(&mut SessionModel)| { + send_ride_model(|model| { + model.automation.push(send_entry( + "t1-b-rr", + 6, + vec![send_piece( + AutomationShape::Linear, + (0, 1.0), + (end, 0.25), + ParameterUnit::Linear, + )], + )); + change(model); + }) + }; + let model = with_rr(&|_| {}); + let untouched = both_run(&model, RUN, &[]); + // (a) A changed gain ride: an exact rebuild, then the new model's bits. + let changed = with_rr(&|model| model.automation[0].segments[0].end_value = -24.0); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + same_blocks(&mut browser, &mut c_abi, 0..EDIT, "(a)"); + both_commit( + &mut browser, + &mut c_abi, + &[SessionEdit::UpsertAutomation { + automation: changed.automation[0].clone(), + }], + EditPath::Rebuild, + "(a)", + ); + let fresh = both_run(&changed, RUN, &[]); + for block in EDIT..RUN { + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "(a), block {block}: the two hosts agree"); + if block as usize >= SETTLED { + assert_eq!( + b, fresh[block as usize], + "(a), block {block}: the new model's bits" + ); + } + } + let watermark = c_abi.watermark(); + assert_eq!(status_watermark(&browser), watermark, "(a): one watermark"); + assert_eq!( + watermark.2, + capi::OUTCOME_EXACT, + "(a): the rebuild is exact" + ); + // (b), (c), (d). + let offset = with_rr(&|model| { + model.automation[0].offset = session::AutomationOffset { + left_db: 3.0, + right_db: 3.0, + }; + }); + let lr = with_rr(&|model| t1_b(model).channel_matrix.lr = 0.5); + for (label, edits, path, expected) in [ + ( + "(b) the gain offset", + send_edit(1, 3.0, None), + EditPath::Live, + &offset, + ), + ( + "(c) an automated matrix_rr", + send_edit(6, 0.75, None), + EditPath::ModelOnly, + &model, + ), + ( + "(d) an un-automated matrix_lr", + send_edit(4, 0.5, None), + EditPath::Live, + &lr, + ), + ] { + let run = both_run(&model, RUN, &[(EDIT, edits, path)]); + let fresh = both_run(expected, RUN, &[]); + let from = if path == EditPath::ModelOnly { + 0 + } else { + SETTLED + }; + assert_eq!( + run[from..], + fresh[from..], + "{label}: the committed model's bits" + ); + if path == EditPath::Live { + assert_ne!( + run[EDIT as usize..], + untouched[EDIT as usize..], + "{label}: heard" + ); + } + } +} + +/// #1654 gate 4 on both hosts (D5). `t1-b`'s own mute curve is open until 2,048 and closed after. +/// A hand mute before block 4 is `live` and, after the mute ramp, renders the plan whose send is +/// statically muted (the curve is open: the hand mute closes the send); a hand unmute before +/// block 20 (2,560, the curve closed) is `live` and renders the curve-only plan (the send stays +/// closed). Both hosts render the same bits. +/// +/// Test value: red if the composition is not an OR on either host, or the hosts disagree. +#[cfg(feature = "test-support")] +#[test] +fn a_hand_send_mute_ors_with_its_curve_on_both_hosts() { + use session::{AutomationShape::Step, ParameterUnit::Linear}; + const RUN: u64 = 32; + let end = u64::from(QUANTUM) * BLOCKS; + let curve = |muted: bool| { + let mut model = follow_model(false, false); + model.automation.push(send_entry( + "t1-b-mute", + 2, + vec![ + send_piece(Step, (0, 0.0), (2_048, 1.0), Linear), + send_piece(Step, (2_048, 1.0), (end, 1.0), Linear), + ], + )); + t1_b(&mut model).mute = muted; + model + }; + let run = both_run( + &curve(false), + RUN, + &[ + (4, send_edit(2, 1.0, None), EditPath::Live), + (20, send_edit(2, 0.0, None), EditPath::Live), + ], + ); + let shut = both_run(&curve(true), RUN, &[]); + let curve_only = both_run(&curve(false), RUN, &[]); + assert_eq!( + run[9..20], + shut[9..20], + "the hand mute closes the open curve" + ); + assert_ne!( + curve_only[9..16], + shut[9..16], + "not vacuous: the curve is open there" + ); + assert_eq!( + run[20..], + curve_only[20..], + "the hand unmute leaves the closed curve closed" + ); +} + +/// #1654 gate 5 on both hosts: an automation entry on the route into the output `t1-main` is +/// refused `control.automation.target` alike, with one diagnostic. +/// +/// Test value: red if a route into the output's rows became automatable on either host (D8). +#[cfg(feature = "test-support")] +#[test] +fn an_output_route_entry_is_refused_alike_on_both_hosts() { + let model = follow_model(false, false); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + same_blocks(&mut browser, &mut c_abi, 0..1, "before"); + let mut entry = send_entry( + "t1-main-gain", + 1, + vec![send_piece( + session::AutomationShape::Linear, + (0, -6.0), + (1_000, -6.0), + session::ParameterUnit::Db, + )], + ); + entry.target.owner = Owner::Route(id("t1-main")); + let edits = [SessionEdit::UpsertAutomation { automation: entry }]; + let frame = browser.encode( + CommandPayload::SessionTransactionApply(&edits), + ExpectedRevision::Exact(SessionRevision(browser.revision)), + ); + let (b_code, b_diagnostic) = browser.send_frame(&frame); + let (c_code, c_diagnostic) = c_abi.send_status( + CommandPayload::SessionTransactionApply(&edits), + ExpectedRevision::Exact(SessionRevision(c_abi.revision)), + ); + // Each host has its own result numbering; the diagnostic is the shared contract. + assert_ne!(b_code, C_RESULT_OK, "the browser refuses the entry"); + assert_ne!(c_code, C_RESULT_OK, "the C ABI refuses the entry"); + assert_eq!(b_diagnostic, c_diagnostic, "one diagnostic"); + let diagnostic = String::from_utf8_lossy(&c_diagnostic); + assert!( + diagnostic.contains("control.automation.target") + && diagnostic.contains("$.automation[id=t1-main-gain].target"), + "{diagnostic}" + ); + same_blocks(&mut browser, &mut c_abi, 1..3, "after the refusal"); +} + +/// #1654 gate 7 and R19-5 on both hosts. On [`send_ride_model`]: (gate 7) a rebuild that adds a +/// muted send `t2-b` into the bus (inactive: no bit of the mix moves) keeps the send ride's table, so the +/// send's cursors carry verbatim and every block is the never-rebuilt run's, bit for bit; +/// (R19-5) a live send move with its own 4,000 samples (the hand offset `+3`, or a D6 +/// `matrix_lr` edit) is in flight when a rebuild moves the gain ride's table: until the move's +/// end every block is the run that took the move and no rebuild. Each rebuild completes `EXACT` +/// with no transition fallback on either host, and both hosts render the same bits. +/// +/// Test value: red if a continued ride is repositioned at an unrelated swap (a re-timed ride), if a +/// reposition cuts or re-times a live send move, or if either rebuild is flagged. +#[cfg(feature = "test-support")] +#[test] +fn send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts() { + const RUN: u64 = 48; + const MOVE: u64 = 4; + const REBUILD: u64 = 6; + const RAMP: u32 = 4_000; + let model = send_ride_model(|_| {}); + // A muted route `t2-b` into the bus: inactive, so the bus sum takes nothing of it. + let mut muted_send = model + .routes + .iter() + .find(|route| route.id.as_str() == "t1-b") + .expect("the send") + .clone(); + muted_send.id = id("t2-b"); + muted_send.source = session::RouteSource::Track { + track_id: id(T2.id), + tap: session::SendTap::PreFader, + }; + muted_send.mute = true; + muted_send.follows_mute = false; + let added_route = SessionEdit::UpsertRoute { route: muted_send }; + let moved_ride = |offset: f32| { + let mut ride = model.automation[0].clone(); + ride.segments[0].end_value = -24.0; + ride.offset = session::AutomationOffset { + left_db: offset, + right_db: offset, + }; + SessionEdit::UpsertAutomation { automation: ride } + }; + let exact = |browser: &mut Rig, c_abi: &CAbi, label: &str| { + let watermark = c_abi.watermark(); + assert_eq!( + status_watermark(browser), + watermark, + "{label}: one watermark" + ); + assert_eq!( + watermark.2, + capi::OUTCOME_EXACT, + "{label}: the rebuild is exact" + ); + 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), + "{label}: no transition fallback" + ); + }; + // Gate 7. + let twin = both_run(&model, RUN, &[]); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + same_blocks(&mut browser, &mut c_abi, 0..REBUILD, "gate 7"); + both_commit( + &mut browser, + &mut c_abi, + &[added_route], + EditPath::Rebuild, + "gate 7", + ); + for block in REBUILD..RUN { + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "gate 7, block {block}: the two hosts agree"); + assert_eq!( + b, twin[block as usize], + "gate 7, block {block}: the never-rebuilt run" + ); + } + exact(&mut browser, &c_abi, "gate 7"); + // R19-5. + for (label, edits, offset) in [ + ("the gain offset", send_edit(1, 3.0, Some(RAMP)), 3.0), + ("matrix_lr", send_edit(4, 0.5, Some(RAMP)), 0.0), + ] { + let moved = both_run(&model, RUN, &[(MOVE, edits.clone(), EditPath::Live)]); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + same_blocks(&mut browser, &mut c_abi, 0..MOVE, label); + both_commit(&mut browser, &mut c_abi, &edits, EditPath::Live, label); + same_blocks(&mut browser, &mut c_abi, MOVE..REBUILD, label); + both_commit( + &mut browser, + &mut c_abi, + &[moved_ride(offset)], + EditPath::Rebuild, + label, + ); + let end = (MOVE * u64::from(QUANTUM) + u64::from(RAMP)) / u64::from(QUANTUM); + for block in REBUILD..end { + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{label}, block {block}: the two hosts agree"); + assert_eq!( + b, moved[block as usize], + "{label}, block {block}: the move runs" + ); + } + exact(&mut browser, &c_abi, label); + } +} From 0d58fbd4dc82f7ac4a51cad11a18001d327cb924 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 14:28:48 +0000 Subject: [PATCH 16/36] Keep a deferral when a discontinuity emits no jump (#1711 U1 fold 2) The fold-1 clear ran before jump_event, so an unchanged value at a discontinuity dropped a deferral that was still owed (in the shared walk a value grid then cut a gate jump). The clear now runs only when the jump is emitted. K1 gate an_unchanged_value_jump_keeps_the_gate_deferral (red before). The G2 parity test compares every block with a fresh pair whose step sits at the fader's node time (the soft-clip slot's 31 samples). Co-Authored-By: Claude Opus 5.5 --- ...ves-own-length-across-a-reposition-swap.md | 58 +++++++++++++++-- crates/automation/src/compose/tests.rs | 23 +++++++ crates/automation/src/events.rs | 19 +++--- hosts/host-web/tests/control_parity.rs | 62 ++++++++++++------- 4 files changed, 128 insertions(+), 34 deletions(-) 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 db78fee33..332b22c0d 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 @@ -457,10 +457,11 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af unchanged. The A2 clear list is now: `reposition`, a seek landing, `clear_deferred_jump()` (the free path of `offsets_jump`, the R17-10 release, the `release_hold_by` sites) and a discontinuity jump of a restartable cell. `defer_jump`'s doc says so. -- **Effect on K1.** A value cursor's own discontinuity jump inside a column-jump deferral now drops - that cursor's deferral (its grid moves again after its own jump, held by its own ramp by the - cell rule) instead of holding its grid to the column ramp's end. The other cursors keep their - deferrals. K = 1 still equals `CellEvents` (one rule); the K1 tests and the allocation test are +- **Effect on K1** (corrected in U1 fold 2). A value cursor's own discontinuity jump inside a + column-jump deferral drops that cursor's deferral only when the jump emits (it then replaces the + ramp: its grid moves again after its own jump, held by its own ramp by the cell rule); a jump + that emits nothing keeps the deferral, which holds the grid to the column ramp's end and lands + there. The other cursors keep their deferrals. K = 1 still equals `CellEvents` (one rule); the K1 tests and the allocation test are green unchanged. - **MAJOR-1 gate (host-core, Simd8 and Simd4).** `a_table_step_inside_a_deferred_moves_ramp_drops_the_deferral` @@ -487,8 +488,10 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af no rebuild; a later block differs (the jump is heard). Red once with the adoption landing at the swap instead of deferring (`settle_adoption`, 44.1 kHz, block 10). A fresh-plan reference with a real step at 4,512 is not bit-equal in this session (first difference about `J / 2` after - 4,512 at every rate: 4,959, 4,998, 5,400 and 5,478); not chased here, phase-2 line in the - report. + 4,512 at every rate: 4,959, 4,998, 5,400 and 5,478). Corrected in U1 fold 2: 4,512 is the + render time; the post-insert console slot `miso.soft-clip` has 31 samples latency, so the + fader's node time is render time less 31 and the deferred jump fires at node 4,481 (correct by + D1). A reference stepped at node 4,481 is bit-equal over every block (U1 fold 2). - **MINOR-1.** `table_only_rebuilds_around_a_level_move_complete_exactly` is deleted. Its no-live-move case moved into G1, `a_level_move_across_a_table_change_keeps_its_length`: a table-only rebuild with no move is adopted and reports `OUTCOME_EXACT` (outcome only: the split @@ -511,3 +514,46 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af tests: automation (43, compose included), builtins-compiler (all features), host-core `stored_fader_automation` (36), `stored_mute_automation` (13), `successor_swap` (140), capi lib (211), host-web `control_parity` (33): green. Realtime policy: 171 regions (unchanged). + +## Decision record: U1 fold 2 (batch 19) + +- **MAJOR-A fixed.** `crates/automation/src/events.rs`, `next_in_run`: fold 1 dropped a pending + deferral at every discontinuity of a restartable cell before `jump_event` ran, and + `jump_event` can emit nothing (the curve already has the target's bits, no held event, the ramp + in flight not lagging): then no ramp replaced the one the deferral waits for. A fired deferral + is still spent before the event; a discontinuity of a restartable cell now drops the deferral + only when its jump emits. `defer_jump`'s doc says so. `no_restart` cells are unchanged. +- **Gate (K1).** `an_unchanged_value_jump_keeps_the_gate_deferral` + (`crates/automation/src/compose/tests.rs`): `gate_and` with the unmute at 1,000 over 480; the + gain flat at -5.9921875 to 1,200, then linear -4 to -8 dB over 1,200..1,712, then -8; its + jump at 1,200 targets the curve at 1,455 (-5.9921875, unchanged). The retargets are exactly + `[(1,000, Jump, 480), (1,480, Jump, 256)]`. Red at the fold's base (4533703d3): `[(1,000, Jump, + 480), (1,216, Grid, 64), (1,280, Grid, 64), ..., (1,664, Grid, 64)]` (the grid cuts the gate + jump, R19-3, and the landing at 1,480 is skipped). +- **Builtins single cell: no host-core gate.** In a single builtins cell the deferral comes from + `settle_adoption`, set only when the curve before the next discontinuity `d` (it is compared at + the move's ramp end, clamped before `d`) differs from the stage's target; the cell's target is + the live move's (`seed_target`), and `reposition` cleared `held`. Every grid event in + `[swap, d)` is held (the deferral holds the grid) and the last one, at or after `d - 64`, aims at + the curve at `d - 1`, which differs, so `held` is set and the jump at `d` emits. That holds + whenever a grid point lies in `[swap, d)`: always when the swap block's first node time is on + the 64-sample grid, as in every host-core harness (quantum 128, no latency). The case is + reachable in principle only at a swap block whose first node time is off the grid (a quantum + not a multiple of 64, or upstream latency, such as the 31 samples above) with `d` before the + first grid point; there the fold-2 rule is the D1 behaviour (the deferral is kept, so the grid + does not cut the live move short). No gate here: the host-core moved-target harness swaps only at + multiples of 128; phase-2 line in the report. +- **MINOR-B fixed.** `a_moved_target_defers_its_jump_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`) now compares every block with a fresh pair whose + table steps at `FREE` in the fader's node time (4 * 128 + 4,000 - `T1_FADER_LATENCY`), given the + same move and, at the rebuild's block, a table-only rebuild that moves nothing (split at + 950,000), both keeping the stored offset: bit-equal over all 60 blocks at the four rates. + `T1_FADER_LATENCY` = 31 is a named constant (the soft-clip slot's descriptor latency; no host + reader reports one strip's upstream latency, the plan reports only the output's, which `t3`'s + limiter raises). Red with it set to 0 (44.1 kHz, block 38). The unrebuilt pair is removed (the + fresh reference subsumes it). +- Gates run: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` and no-feature + `cargo check --all-targets` on automation and host-web; tests: automation (all), builtins-compiler + (all features), host-core `stored_fader_automation` (36), `stored_mute_automation` (13), + `successor_swap` (140), capi lib (211, 1 ignored), host-web `control_parity` (37): green. + Realtime policy: 171 regions (unchanged). diff --git a/crates/automation/src/compose/tests.rs b/crates/automation/src/compose/tests.rs index 15bcfd7fb..ab653cb25 100644 --- a/crates/automation/src/compose/tests.rs +++ b/crates/automation/src/compose/tests.rs @@ -543,3 +543,26 @@ fn a_caller_retarget_holds_the_value_grid_until_its_end() { assert_eq!(events.first(), Some(&first), "record length {length}"); } } + +/// R19-3 with a value jump that changes nothing (#1711 U1 fold 2): the unmute at 1,000 jumps over +/// 480 and defers the value cursor to 1,480. The gain is flat at -5.9921875 to 1,200, then linear +/// from -4 to -8 over 1,200..1,712; its discontinuity at 1,200 targets the curve at 1,455 +/// (-5.9921875, its value already), so its jump emits nothing and replaces no ramp. The deferral +/// stays owed: the grid is held until 1,480, where the gain lands over its own length. +/// +/// Red if a discontinuity whose jump emits nothing drops the deferral (the grid then cuts the +/// unmute's jump at 1,216 and the landing at 1,480 is skipped). +#[test] +fn an_unchanged_value_jump_keeps_the_gate_deferral() { + let gain = [ + seg(0, 1_200, -5.992_187_5, -5.992_187_5, Shape::Step), + seg(1_200, 1_712, -4.0, -8.0, Shape::Linear), + seg(1_712, 9_000, -8.0, -8.0, Shape::Step), + ]; + let (program, mut composed) = gate_and(&gain, [480, 256]); + let (events, _) = retargets(&program, &mut composed, gated, 1_480); + assert_eq!( + events, + [(1_000, EventKind::Jump, 480), (1_480, EventKind::Jump, 256)] + ); +} diff --git a/crates/automation/src/events.rs b/crates/automation/src/events.rs index 7594a3baf..d96fe6bef 100644 --- a/crates/automation/src/events.rs +++ b/crates/automation/src/events.rs @@ -218,8 +218,9 @@ impl CellState { /// stage remembers it and does not retarget), so no grid ramp cuts the running ramp short; a /// `no_restart` cell holds them inside the ramp in flight as it always does. Cleared by /// [`Self::clear_deferred_jump`], [`Self::reposition`], a seek landing and, on a restartable - /// cell, a discontinuity jump (it compares the curve and replaces the ramp the deferral waits - /// for). Control-free and allocation-free. + /// cell, a discontinuity jump that emits (it replaces the ramp the deferral waits for; one + /// that emits nothing replaces no ramp and keeps the deferral). Control-free and + /// allocation-free. pub const fn defer_jump(&mut self, at: i64) { self.deferred = at; } @@ -632,11 +633,7 @@ impl<'a, 's> CellEvents<'a, 's> { if is_boundary { self.state.cursor += 1; } - // A discontinuity jump on a restartable cell drops a pending deferral (#1711 U1 fold - // 1): the jump compares the curve with the current target here and replaces the ramp - // the deferral waits for, so the deferral and its grid hold are stale (they would - // freeze the lane on the jump's target until the deferral's time). - if is_deferred || (is_boundary && !self.program.no_restart) { + if is_deferred { self.state.deferred = IDLE; } self.position = offset + 1; @@ -645,6 +642,14 @@ impl<'a, 's> CellEvents<'a, 's> { } else { self.grid_event(offset, next) }; + // A discontinuity jump on a restartable cell that emits drops a pending deferral + // (#1711 U1 fold 1): it replaces the ramp the deferral waits for, so the deferral and + // its grid hold are stale (they would freeze the lane on the jump's target until the + // deferral's time). A jump that emits nothing (#1711 U1 fold 2: the curve already has + // its value and no ramp lags) replaces no ramp, and the deferral stays owed. + if is_boundary && !self.program.no_restart && event.is_some() { + self.state.deferred = IDLE; + } if event.is_some() { return event; } diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 47673a7cd..9763e128b 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -3015,23 +3015,34 @@ fn a_hand_move_in_flight_across_a_table_only_rebuild_completes_exactly_alike_on_ } } +/// The latency upstream of `t1`'s fader in [`model_at`]'s session, in samples: the console +/// post-insert slot `miso.soft-clip` declares 31 (its descriptor's quality latency); the +/// pre-insert compressor and `t1`'s EQ and compressor inserts declare 0. The fader's node time is +/// render time less it. No host reader reports one strip's upstream latency (the plan reports +/// only the output's, which `t3`'s limiter raises), so it is named here; the bit-equality of +/// [`a_moved_target_defers_its_jump_alike_on_both_hosts`] fails if it is wrong. +const T1_FADER_LATENCY: u64 = 31; + /// #1711 G2 across hosts: on a fader ride flat at -3 dB, a hand move with its own 4,000 samples /// heard 3 blocks, then a table-only rebuild to -5 dB up to 20,000, then -3 dB (the move's target /// moves; the rebuild keeps the hand move's stored offset, R17-1): the adoption defers the cell's -/// jump to the move's first free sample (4,512) on both hosts alike, through the one shared -/// driver. Both hosts give one verdict, reply and committed document per edit, render the same -/// bits in every block, through the move's end and the deferred jump over the fader length, and -/// the rebuild completes exactly on both (`OUTCOME_EXACT`, no transition fallback; #1711 D2). -/// Every block before the one holding 4,512 equals a pair that takes the same move and no -/// rebuild (the move keeps its own ramp), and a later block differs from it (the jump is heard). +/// jump to the move's first free sample on both hosts alike, through the one shared driver. Both +/// hosts give one verdict, reply and committed document per edit, render the same bits in every +/// block, and the rebuild completes exactly on both (`OUTCOME_EXACT`, no transition fallback; +/// #1711 D2). Every block, at the four rates, is bit-equal to a fresh pair whose table has the +/// jump as a real step at the move's first free sample in the fader's node time (render time +/// 4,512 less [`T1_FADER_LATENCY`]), given the same move and, at the same block, a table-only +/// rebuild that moves nothing (a split at 950,000), both keeping the stored offset. /// /// Test value: red if either host adopts the moved target differently (an adapter-local rule, or /// a browser successor that does not carry the stage's ramp the deferral waits for), or if the /// shared adoption does not defer the jump to the move's first free sample (it lands at the swap, -/// or the grid cuts the move short). +/// fires at another time, or the grid cuts the move short). #[test] fn a_moved_target_defers_its_jump_alike_on_both_hosts() { - const FREE: u64 = 4 * QUANTUM as u64 + 4_000; + // The move's first free sample in the fader's node time: its block-4 entry in render time plus + // its 4,000 samples, less the latency upstream of `t1`'s fader. + const FREE: u64 = 4 * QUANTUM as u64 + 4_000 - T1_FADER_LATENCY; let ride = |pieces: &[(u64, u64, f32)], offset| SessionEdit::UpsertAutomation { automation: session::Automation { id: id("t1-fader-ride"), @@ -3068,6 +3079,17 @@ fn a_moved_target_defers_its_jump_alike_on_both_hosts() { let model = model_at(rate); let (mut browser, mut c_abi) = (Rig::new(&model), CAbi::new(&model)); let (mut browser_r, mut c_abi_r) = (Rig::new(&model), CAbi::new(&model)); + let reference = |split: u64, offset| { + ride( + &[ + (0, FREE, -3.0), + (FREE, 20_000, -5.0), + (20_000, split, -3.0), + (split, 1_000_000, -3.0), + ], + offset, + ) + }; let both = |edits: &[SessionEdit], browser: &mut Rig, c_abi: &mut CAbi, label: &str| { let (b, b_reply) = browser.transaction_reply(edits); let (c, c_reply) = c_abi.transaction(edits); @@ -3081,16 +3103,15 @@ fn a_moved_target_defers_its_jump_alike_on_both_hosts() { "the flat ride", ); both( - &[ride(&[(0, 1_000_000, -3.0)], none)], + &[reference(900_000, none)], &mut browser_r, &mut c_abi_r, - "the unrebuilt ride", + "the reference ride", ); - let mut heard = false; for block in 0..60 { if block == 4 { both(&hand, &mut browser, &mut c_abi, "the hand move"); - both(&hand, &mut browser_r, &mut c_abi_r, "the unrebuilt move"); + both(&hand, &mut browser_r, &mut c_abi_r, "the reference move"); } if block == 7 { let committed = @@ -3119,20 +3140,19 @@ fn a_moved_target_defers_its_jump_alike_on_both_hosts() { &mut c_abi, "the rebuild", ); + both( + &[reference(950_000, offset)], + &mut browser_r, + &mut c_abi_r, + "the reference rebuild", + ); } let (b, c) = (browser.step(block), c_abi.step(block)); assert_eq!(b, c, "{rate} Hz: block {block}"); let (b_r, c_r) = (browser_r.step(block), c_abi_r.step(block)); - assert_eq!(b_r, c_r, "{rate} Hz: block {block}, the unrebuilt move"); - if block < FREE / u64::from(QUANTUM) { - assert_eq!( - b, b_r, - "{rate} Hz: block {block} against the unrebuilt move" - ); - } - heard |= b != b_r; + assert_eq!(b_r, c_r, "{rate} Hz: block {block}, the reference"); + assert_eq!(b, b_r, "{rate} Hz: block {block} against the reference"); } - assert!(heard, "{rate} Hz: the deferred jump is heard"); let watermark = c_abi.watermark(); assert_eq!( status_watermark(&browser), From 4d700eee763585549ff2bcb43ca13ef5c2571e3b Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 14:38:49 +0000 Subject: [PATCH 17/36] Compose VCA curves on each reached member lane in the fader stage (#1655 V2) VCA cells join the one program after the route cells. A member lane an automated VCA reaches composes one state over its own cell and the VCA cells (offsets summed in f64 with S first; mute ORed with terms) on the shared walk with a held grid (opt-in, so a step keeps its J while another curve rides); lanes no VCA curve reaches keep the CellEvents path and bits. The offsets cell gains own_db; carry requires the reaching VCA tables to be unchanged; pair state is charged as bank payload. Realtime policy 172. Co-Authored-By: Claude Opus 5.5 --- ...r-stored-vca-offset-and-mute-automation.md | 87 + crates/automation/src/compose.rs | 78 +- crates/automation/src/compose/tests.rs | 61 + crates/builtins-compiler/src/lib.rs | 2362 ++++++++++++++--- crates/controls/src/automation.rs | 63 +- crates/controls/tests/automation.rs | 58 + crates/host-core/src/prepare.rs | 1 + .../tests/stored_fader_automation.rs | 5 + .../host-core/tests/stored_mute_automation.rs | 1 + 9 files changed, 2387 insertions(+), 329 deletions(-) diff --git a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md index 24b7bafae..c6eff4581 100644 --- a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md +++ b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md @@ -451,3 +451,90 @@ For V1: a VCA automation edit and an automated VCA's reach change are now step 3 VCA offset diffs as a VCA row 1 change whose value is the offset and moves `S` of each reached lane, which host-core lowers only on lanes that have an offsets cell until V1 gives every event lane one. + +## Decision record: V2 (batch 19) + +The fader stage (`crates/builtins-compiler/src/lib.rs`), one additive rule in K1 +(`crates/automation/src/compose.rs`), the carry predicates (`crates/controls/src/automation.rs`). +No edit under `crates/engine/src/realtime/`; no row class or table change (no SDK regeneration). + +- **Program (D1).** The VCA cells follow every route cell, in ascending VCA ID, then row + (`offset_db` with `FADER_CELL_RULE`, `mute` with `MUTE_CELL_RULE`), then lane; one cell per + automated VCA lane, shared by its members (refusal code `host.automation.vca`). The reach is read + only when an entry drives a VCA row, so a session without VCA automation builds its program cell + for cell. Export for V3/V4: `PreparedBuiltinsSession::vca_cells(vca_id) -> Option<(Arc, + VcaCells)>` (`[row][lane]`). `strip_mute_cells` stays own-entry cells (a lane only a VCA mute + reaches reads `None` there). +- **Event lanes (D2, D4, R19-2 = (b)).** Preparation reads `controls::fader_event_lanes` and + `mute_event_lanes`. A lane no automated VCA curve reaches keeps `FaderCurve::Cell` / + `MuteCurve::Cell`: the same `CellEvents` path and bits as before. A reached lane is + `FaderCurve::Composed` (one `automation::Composed` over its own cell, when automated, then + the VCA cells in ascending VCA ID) or `MuteCurve::Composed` (`Composed`, gate cursors, value + `own || v_1 || ... || v_m`; the stage mute is that `|| terms`). The composed value is the fader dB + `vca_compose_db(own, S + c_1 + ... + c_m)` (`composed_db`: `f64` fold, `S` first, inside the + render entry's `CanonicalFpEnv`; the prepared value inside preparation's), `own` the own curve or + `own_db`; the stage gain is `checked_fader_gain` of it. +- **One driver (R17-4).** `FaderEventDriver` (bank, fused pair fallback, scalar oracle) holds a + `ChannelEvents::{Cell, Composed}` per channel: each piece begins the walk (`Composed::start`), the + cut at `E` is a one-sample `Ramp` landing on every cursor (longest jump, R19-4), and the held-ramp + obligation, the R17-10 release, the T3b re-hold release, the held-stage offsets jump and the free + offsets jump act on every cursor (`FaderComposite::each_state`). `settle_adoption` is generic over + `AdoptedCurve` (`CellAdoption`, `ComposedAdoption`): one adoption rule (F0 ramp target, the + deferred jump at the ramp's first free sample, grid hold, clears) for both shapes. + `block_jumps` resolves `LiveRamps::for_row(VcaOffset)` and `for_row(VcaMute)` (slot field + `vca_jumps`); a VCA cursor takes its row's length, the own cursor the fader or mute length. +- **D3.** `OFFSETS_CELL_WORDS = 4`. `FaderOffsetsRecord` gains `own_db: Option` (word 3, a + quiet NaN for `None`); a composed lane whose own fader is not automated takes `Some(own_db)` and + jumps at the block entry only when the gain bits change; `None` keeps it. +- **Carry (D6).** `continued_fader_lanes` / `continued_mute_lanes` now continue a lane only when its + own table (absent in both, or the same) and each reaching automated VCA table (same VCAs, ascending + ID, same segments) continue; with no VCA row automated the rule is the old one. `FaderCurve::carry` + / `MuteCurve::carry`: same shape copies the cell state, or the composed output's state and target + and, with one layout, the cursors; a changed shape takes the cell's ramp or starts fresh (always a + reposition). A composed reposition places every cursor afresh (no target, hears its curve at the + block start), so a deferred jump or a landing reaches every curve. `own_db` is carried like `S` + (`adopted_own_db`, the `Compare` backstop). +- **Memory (R19-6).** Each composed channel charges its boxed curve (`fader_composite_bytes` 128, + `mute_composite_bytes` 112) and its cursor table (`cursor_bytes` 104 per pair) as bank payload and + in the scalar owner row (`FaderAutomationPlan::composed_bytes`), under the existing caps; the + offsets-writer charge widens to fader event lanes. The cap gate is V5's. + +**Departures.** +1. K1 additive rule `Composed::with_held_grid` (opt-in; the route keeps R19-3). Without it a grid + event of one curve retargets the composed lane inside another curve's jump (the R19-V1 (a) + defect: the step ends at the next grid point, not at `J`). With it each retarget reports its ramp + to every value cursor whose own ramp ends earlier, and the cell's own grid rule holds or restarts + it. Also `clear_target`, `output_state`, `set_output` (carry). Test + `compose::tests::a_step_keeps_its_jump_length_while_another_curve_rides_on_a_held_grid_output`. +2. An offsets record on a composed lane takes the single cell's rule (seed, clear the deferral, + every cursor holds to the jump's completion), not `Composed::retarget` (deferral then each + cursor's own `J`), so a composed lane moves as one cell does. +3. `own_db` is `Option`: today's host-core record sites write `None` (mechanical, correct for + every lane that has a cell today); V3 writes `Some` on static-own event lanes. +4. `LaneFaderAutomation::new` returns `Result` (the cursor table allocates); bind + (`bound_lane_automation`) panics only on an allocation failure, as a `Box::new` there does. + +**Gates** (`cargo test --locked -p builtins-compiler --all-features`, Simd8 under AVX2 and Simd4): +`a_flat_vca_ride_renders_the_static_vca_offset_bits` (flat ride `-6` dB and flat mute `0` equal the +static VCA bits over 40 blocks, a member with and without its own ride, fallback `+3` unused), +`a_vca_ride_composes_with_each_members_own_value_at_every_grid_event`, +`a_step_inside_another_curves_ride_keeps_its_jump_length` (both ways; red without `with_held_grid`: +`(1024, Grid)` after the step), `coincident_member_and_vca_events_take_the_longest_jump` (fader and +mute, 480/960 both ways), `a_session_without_vca_offset_automation_keeps_its_lone_fader_cells`, +`a_carried_composed_lane_renders_the_uninterrupted_bits` (continued and repositioned, swap inside a +grid ramp and inside a VCA step's jump; red without the cursor carry). `controls`: +`a_member_lane_continues_only_while_its_reaching_vca_tables_do`. + +Commands: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` and no-feature +`cargo check --locked --all-targets` on automation, builtins, builtins-compiler, host-core, controls; +tests automation (all features), builtins (all features), builtins-compiler (all features, 76 lib), +controls, host-core `--features test-support` `stored_fader_automation` (36), `stored_mute_automation` +(13), `successor_swap` (140): all green. Realtime policy: 172 regions in 35 files (171 before: the +composed-curve impls). Sizes (x86-64, measured): `Option` 392 -> 416 B +(`vca_jumps`), `offsets_cell_bytes` 120 (unchanged), `ComposeCursor` 104. + +For V3: write `own_db = Some(static fader dB)` on fader event lanes with no own automation; take the +fader event lanes as `automated` in `level_records_between` / `fader_records` (no `FaderDb` must reach +a composed lane: the drain still applies a `FaderDb` to the stage); `has_offsets` is true on every +fader event lane. For V4: a source lane's VCA mute cells are its reach (ascending VCA ID) mapped +through `vca_cells(..)[1][lane]`, jump row `VcaMute`. diff --git a/crates/automation/src/compose.rs b/crates/automation/src/compose.rs index d6fabd67b..4b1130021 100644 --- a/crates/automation/src/compose.rs +++ b/crates/automation/src/compose.rs @@ -216,6 +216,9 @@ pub struct Composed { completion: Completion, /// The block begun by [`Self::start`] and its seek mode, until its events end. block: Option<(NodeSpan, SeekMode)>, + /// [`Self::with_held_grid`]: every output retarget holds each value cursor's grid inside the + /// retarget's ramp, as one cell holds its grid inside its own ramp (#1655 V2, R19-2). + held_grid: bool, } impl Composed { @@ -273,8 +276,25 @@ impl Composed { target: None, completion, block: None, + held_grid: false, }) } + + /// The output with the one-cell hold rule on its composed value (#1655 V2, R19-2: a member + /// fader channel over its own curve and the reaching VCA curves): every retarget of the output + /// (a grid or jump event, a ramped landing) reports its ramp to each value cursor whose own + /// ramp in flight completes earlier ([`CellState::hold_until`]), so a cursor's grid event + /// whose ramp would complete inside it is held and one that completes later restarts it, by + /// the cell's own grid rule. A step of one curve then keeps its jump length while another + /// curve rides (and the reverse); coincident events already take the longest ramp (R19-4). + /// Without it (a route column, R19-3) a value jump leaves the other value cursors free. With + /// one cursor the hold is the cursor's own ramp, so the output's events are still the cell's + /// [`CellEvents`]. Control side. + #[must_use] + pub fn with_held_grid(mut self) -> Self { + self.held_grid = true; + self + } } // REALTIME_POLICY_BEGIN @@ -349,6 +369,26 @@ impl Composed { self.state.seed_target(0.0); } + /// Forgets the output's target: its next grid or jump event retargets whatever its value (the + /// one-cell rule of a fresh state, for a stage that only remembered a gain, #1638 D2). The + /// ramp in flight is kept. + pub fn clear_target(&mut self) { + self.target = None; + } + + /// The output's own event state (its ramp in flight and held flag), for a carry. + #[must_use] + pub const fn output_state(&self) -> &CellState { + &self.state + } + + /// Sets the output's own event state and target (a carry of a predecessor's output, or of a + /// single cell's state with no target); the cursors are untouched. + pub fn set_output(&mut self, state: CellState, target: Option) { + self.state = state; + self.target = target; + } + /// Seeds the output's target with the composition of the heard values (preparation). pub fn seed(&mut self, compose: impl FnOnce(Heard<'_>) -> T) { let value = compose(self.heard(Source::Heard)); @@ -432,6 +472,7 @@ impl Composed { self.target = other.target; self.completion = other.completion; self.block = other.block; + self.held_grid = other.held_grid; true } @@ -511,6 +552,15 @@ impl Composed { } else { None }; + // `with_held_grid`: the completion of the ramp this offset retargets the output over + // (a landing too), which every value cursor holds its grid inside. + let hold = event + .filter(|event| self.held_grid && !event.held && event.ramp > 0) + .map(|event| crate::completion_of(self.completion, time, event.ramp)); + let completion = self.completion; + let short = |state: &CellState, hold: i64| { + state.free_after(completion).is_none_or(|free| free <= hold) + }; let next = offset.saturating_add(1); for cursor in self.cursors.iter_mut() { cursor.shown = cursor.heard; @@ -530,14 +580,34 @@ impl Composed { && pending.is_some_and(|at| at > time && at < free) }); let defer = if column_jump { free } else { extend }; - if let (Some(free), true) = (defer, value) { + // The hold reaches a cursor whose own ramp in flight completes earlier: for one + // that pulled ahead, the state its pull started from. + let held = hold.filter(|&hold| { + value + && short( + if cursor.consumed { + &cursor.state + } else { + &cursor.before + }, + hold, + ) + }); + if value && (defer.is_some() || held.is_some()) { if !cursor.consumed { // The cursor pulled past this offset before the column ramp changed: - // replay its pull up to here (it gave no event there), then defer and - // pull again. + // replay its pull up to here (it gave no event there), then defer (or + // hold) and pull again. replay(cursor, program, &span, mode, next); } - cursor.state.defer_jump(free); + if let Some(hold) = held + && short(&cursor.state, hold) + { + cursor.state.hold_until(hold); + } + if let Some(free) = defer { + cursor.state.defer_jump(free); + } pull(cursor, program, &span, mode, next, frames); } else if cursor.consumed { pull(cursor, program, &span, mode, next, frames); diff --git a/crates/automation/src/compose/tests.rs b/crates/automation/src/compose/tests.rs index 15bcfd7fb..d409e00e6 100644 --- a/crates/automation/src/compose/tests.rs +++ b/crates/automation/src/compose/tests.rs @@ -543,3 +543,64 @@ fn a_caller_retarget_holds_the_value_grid_until_its_end() { assert_eq!(events.first(), Some(&first), "record length {length}"); } } + +fn summed(heard: Heard<'_>) -> f32 { + heard.value(0) + heard.value(1) +} + +/// #1655 V2 (R19-2), `with_held_grid`: a step of one value curve keeps its jump length while +/// another rides, both ways round. The step at 1,000 jumps over 480; the riding curve's grid +/// points whose ramps would end inside that jump (1,024 to 1,408) are held, and the first one +/// whose ramp ends after it (1,472) retargets again, by the one-cell rule. Without the rule (a +/// route column, R19-3) the riding curve's grid point at 1,024 cuts the jump short. +/// +/// Red if a grid event inside another curve's jump retargets a held-grid output, or if the hold +/// outlasts the jump. +#[test] +fn a_step_keeps_its_jump_length_while_another_curve_rides_on_a_held_grid_output() { + let ride = [seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let step = [ + seg(0, 1_000, -6.0, -6.0, Shape::Step), + seg(1_000, 9_000, -12.0, -12.0, Shape::Step), + ]; + for order in [[&ride[..], &step[..]], [&step[..], &ride[..]]] { + let program = + ProgramBuilder::build(&[fader_cell(order[0]), fader_cell(order[1])]).expect("program"); + let bind = |held: bool| { + let composed = Composed::::new( + &program, + &[(0, CursorKind::Value), (1, CursorKind::Value)], + Completion::End, + ) + .expect("bind"); + let mut composed = if held { + composed.with_held_grid() + } else { + composed + }; + composed.seed(summed); + for cursor in composed.cursors_mut() { + cursor.set_jump(480); + } + composed + }; + let after_step = |events: &[(u32, EventKind, u32)]| { + events + .iter() + .copied() + .filter(|&(offset, _, _)| offset >= 1_000) + .take(2) + .collect::>() + }; + let (held, _) = retargets(&program, &mut bind(true), summed, 1_000); + assert_eq!( + after_step(&held), + [(1_000, EventKind::Jump, 480), (1_472, EventKind::Grid, 64)] + ); + let (free, _) = retargets(&program, &mut bind(false), summed, 1_000); + assert_eq!( + after_step(&free), + [(1_000, EventKind::Jump, 480), (1_024, EventKind::Grid, 64)] + ); + } +} diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index f55aaf362..fc587ba06 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -310,30 +310,51 @@ struct OffsetsWriter { writer: CellWriter, } -/// One automated fader channel's offsets-cell value (#1649 D4): the lane's offset sum `S` -/// (`controls::EffectiveStrip::offsets_sum`) and the length of the jump a change of it asks for -/// (the lane's fader length of the edit, `LevelRules::lengths`). A write stores both in one slot, -/// so render never reads the halves of two sums. +/// One fader event lane's offsets-cell value (#1649 D4, #1655 D3): the lane's offset sum `S` +/// (`controls::EffectiveStrip::offsets_sum`), the length of the jump a change of it asks for (the +/// lane's fader length of the edit, `LevelRules::lengths`) and, on an event lane whose own fader +/// is not automated, the member's static fader dB `own_db`. A write stores them in one slot, so +/// render never reads the halves of two sums or a sum with another `own_db`. #[derive(Clone, Copy, Debug, PartialEq)] pub struct FaderOffsetsRecord { /// `S`. pub offsets_sum: f64, /// The jump's length in samples; `0` is an exact set. pub smoothing_samples: u32, + /// The member's static fader dB (#1655 D3), on a fader event lane whose own fader is not + /// automated: its hand fader move, which the lane composes with `S` and the reaching VCA + /// curves. `None` keeps the lane's `own_db`; a lane whose own fader is automated reads no + /// `own_db` (its own value is its curve). A `Some` is finite. + pub own_db: Option, } +/// The `own_db` word of a record with no `own_db` (a quiet NaN: a `Some` is finite). +const NO_OWN_DB: u32 = 0x7fc0_0000; + impl FaderOffsetsRecord { - /// The cell's words: `S`'s `f64` bits, low word first, then the ramp word. + /// The cell's words: `S`'s `f64` bits, low word first, the ramp word, then the `own_db` bits + /// ([`NO_OWN_DB`] for `None`). const fn words(self) -> [u32; OFFSETS_CELL_WORDS] { let bits = self.offsets_sum.to_bits(); - [bits as u32, (bits >> 32) as u32, self.smoothing_samples] + let own = match self.own_db { + Some(own) => own.to_bits(), + None => NO_OWN_DB, + }; + [ + bits as u32, + (bits >> 32) as u32, + self.smoothing_samples, + own, + ] } /// The record a slot's words hold. const fn from_words(words: [u32; OFFSETS_CELL_WORDS]) -> Self { + let own = f32::from_bits(words[3]); Self { offsets_sum: f64::from_bits((words[0] as u64) | ((words[1] as u64) << 32)), smoothing_samples: words[2], + own_db: if own.is_nan() { None } else { Some(own) }, } } } @@ -713,10 +734,12 @@ fn matrix_lane(superseded: &LiveValuesSuperseded) -> (MatrixCells, MatrixReader) ) } -/// Words of an automated fader lane's offsets cell (#1646 D1, written by #1649): the lane's offset -/// sum `S` as the low and high words of its `f64` bits, then the ramp word (the jump length a VCA -/// edit asks for). -const OFFSETS_CELL_WORDS: usize = 3; +/// Words of a fader event lane's offsets cell (#1646 D1, written by #1649): the lane's offset sum +/// `S` as the low and high words of its `f64` bits, the ramp word (the jump length a VCA edit asks +/// for), then the `own_db` word (#1655 D3, batch 19 amendment: a slot of four words and one of +/// three are both 32 bytes, so every offsets cell takes it; unread on a lane whose own fader is +/// automated). +const OFFSETS_CELL_WORDS: usize = 4; /// What one automated fader channel's offsets cell shares between its writer (control, /// [`FaderCells::write_offsets`]) and its reader (render, [`LaneFaderAutomation::read_offsets`]): @@ -751,10 +774,9 @@ impl core::fmt::Debug for OffsetsLane { /// The event state lives here, beside the lane's ramp state, so the stage's carry moves it with /// the lane (#1648 extends [`FaderBankProcessor::adopt_lane`]). struct AutomatedFaderCell { - /// The cell's index in the plan's [`automation::Program`]. - cell: u32, - /// Seeded with the prepared curve value, so the first grid event on a flat curve emits nothing. - state: automation::CellState, + /// The channel's curve: its own cell, or the composed curve of a fader event lane an automated + /// VCA `offset_db` lane reaches (#1655 V2). + curve: FaderCurve, /// The offsets cell (#1649), shared with the strip's [`FaderCells`] when the strip has a live /// control lane. offsets: Arc, @@ -783,6 +805,348 @@ struct AutomatedFaderCell { adopted_sum: Option, } +/// The curve of one fader event channel (#1646 D1, #1655 V2). +#[derive(Debug)] +enum FaderCurve { + /// The channel's own fader cell alone: the lane is automated and no automated VCA `offset_db` + /// lane reaches it. Its events are the cell's [`automation::CellEvents`], as before #1655, so + /// no bit of a session without VCA automation moves. + Cell { + /// The cell's index in the plan's [`automation::Program`]. + cell: u32, + /// Seeded with the prepared curve value, so the first grid event on a flat curve emits + /// nothing. + state: automation::CellState, + }, + /// The lane's composed curve (R19-2 = (b)): one output over its own curve (when its fader is + /// automated) and every reaching automated VCA `offset_db` curve. + Composed(Box), +} + +/// The composed curve of a fader event lane that automated VCA `offset_db` lanes reach (#1655 D2, +/// R19-2): one [`automation::Composed`] output whose cursors are the lane's own fader cell (when +/// its fader is automated) and then each reaching automated VCA lane's cell, in ascending VCA ID. +/// Its value is the lane's fader dB `vca_compose_db(own, S + c_1 + ... + c_m)` (the `f64` sum in +/// D2's order: `S` first, then the curves in cursor order), `own` the own curve's value or the +/// member's static `own_db` (D3). The output keeps one cell's hold rule +/// ([`automation::Composed::with_held_grid`]), so a step of one curve keeps its jump length while +/// another rides; every other rule of the single cell (the held-ramp obligation, the R17-10 +/// release, the adoption and its deferred jump, #1711) acts on it through the same driver, +/// applied to every cursor. +#[derive(Debug)] +struct FaderComposite { + walk: automation::Composed, + /// Cursor 0 is the lane's own fader cell. + own_curve: bool, + /// The member's static fader dB (D3), the lane's own value when `own_curve` is `false`: the + /// prepared one until an offsets record carries another. + own_db: f32, + /// At an adopted channel until its first block's offsets reads: the `own_db` this plan was + /// prepared with (the rule of [`AutomatedFaderCell::adopted_sum`]). + adopted_own_db: Option, +} + +/// The curve of one mute event channel (#1651 D1, #1655 V2). +#[derive(Debug)] +enum MuteCurve { + /// The channel's own mute cell alone, as before #1655. + Cell { + /// 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 lane's composed curve: one output over its own mute cell (when its mute is automated) + /// and every reaching automated VCA `mute` lane's cell, in ascending VCA ID; its value is + /// `own || v_1 || ... || v_m` (D4). The stage mute is that value `|| terms`. + Composed(Box), +} + +/// The composed mute curve of a mute event lane that automated VCA `mute` lanes reach (#1655 D4). +#[derive(Debug)] +struct MuteComposite { + walk: automation::Composed, + /// Cursor 0 is the lane's own mute cell. + own_curve: bool, +} + +impl FaderCurve { + /// The prepared channel: its own cell alone when no automated VCA lane reaches it, else the + /// composed curve over its cells, seeded with the lane's prepared fader dB. Control side: a + /// composed channel allocates its cursor table. + fn prepared( + program: &automation::Program, + cell: &PreparedFaderCell, + ) -> Result { + if let (Some(own), true) = (cell.cell, cell.vca.is_empty()) { + let mut state = automation::CellState::new(); + state.seed_target(cell.value); + return Ok(Self::Cell { cell: own, state }); + } + let cells: Vec<(u32, automation::CursorKind)> = cell + .cell + .into_iter() + .chain(cell.vca.iter().copied()) + .map(|cell| (cell, automation::CursorKind::Value)) + .collect(); + let mut walk = + automation::Composed::new(program, &cells, controls::FADER_CELL_RULE.completion)? + .with_held_grid(); + // The prepared value, composed once inside preparation's `CanonicalFpEnv` (D2, R17-7). + walk.seed_target(cell.fader_db); + Ok(Self::Composed(Box::new(FaderComposite { + walk, + own_curve: cell.cell.is_some(), + own_db: cell.own_db, + adopted_own_db: None, + }))) + } +} + +impl MuteCurve { + /// The prepared channel: its own cell alone when no automated VCA mute lane reaches it, else + /// the composed curve over its cells, seeded with the prepared curve mute. Control side. + fn prepared( + program: &automation::Program, + cell: &PreparedMuteCell, + ) -> Result { + if let (Some(own), true) = (cell.cell, cell.vca.is_empty()) { + let mut state = automation::CellState::new(); + state.seed_target(cell.value); + return Ok(Self::Cell { cell: own, state }); + } + let cells: Vec<(u32, automation::CursorKind)> = cell + .cell + .into_iter() + .chain(cell.vca.iter().copied()) + .map(|cell| (cell, automation::CursorKind::Gate)) + .collect(); + let mut walk = + automation::Composed::new(program, &cells, controls::MUTE_CELL_RULE.completion)?; + walk.seed_target(cell.curve_muted); + Ok(Self::Composed(Box::new(MuteComposite { + walk, + own_curve: cell.cell.is_some(), + }))) + } +} + +// REALTIME_POLICY_BEGIN +/// A composed lane's fader dB (#1655 D2): `vca_compose_db(own, S + c_1 + ... + c_m)`, the `f64` +/// sum in order, `S` first. Render runs it inside the render entry's `CanonicalFpEnv` (R17-7). +fn composed_db(own: f32, offsets_sum: f64, curves: impl Iterator) -> f32 { + let sum = curves.fold(offsets_sum, |sum, value| sum + f64::from(value)); + session::vca_compose_db(own, sum) +} + +/// Copy the cursor states and heard values of `from` into `into` when both tables have one +/// layout (the same own cursor and as many VCA cursors: a continued channel's, whose tables +/// continue bit for bit); a repositioned channel places its cursors again. +fn carry_cursors( + into: &mut automation::Composed, + from: &automation::Composed, +) { + if into.cursors().len() != from.cursors().len() { + return; + } + for (cursor, carried) in into.cursors_mut().iter_mut().zip(from.cursors()) { + *cursor.state_mut() = *carried.state(); + cursor.set_heard(carried.heard()); + } +} + +impl FaderComposite { + /// The composition of the heard values with `S = offsets_sum`. + fn composition(&self, offsets_sum: f64) -> impl FnMut(automation::Heard<'_>) -> f32 + use<> { + let (own_curve, own_db) = (self.own_curve, self.own_db); + move |heard| { + let first = usize::from(own_curve); + let own = if own_curve { heard.value(0) } else { own_db }; + composed_db( + own, + offsets_sum, + (first..heard.len()).map(|index| heard.value(index)), + ) + } + } + + /// The lane's value with every curve read by `read(cell)` (a cell the program does not hold + /// reads its heard value), composed with `offsets_sum`. + fn compose_with( + &self, + program: &automation::Program, + offsets_sum: f64, + mut read: impl FnMut(&automation::CellProgram<'_>) -> f32, + ) -> f32 { + let cursors = self.walk.cursors(); + let mut values = cursors.iter().map(|cursor| { + program + .cell(cursor.cell() as usize) + .map_or(cursor.heard(), |cell| read(&cell)) + }); + let own = if self.own_curve { + values.next().unwrap_or(self.own_db) + } else { + self.own_db + }; + composed_db(own, offsets_sum, values) + } + + /// The composed value toward a ramp started at `start` and completing at `at`: each curve by + /// [`curve_toward`]. + fn toward(&self, program: &automation::Program, offsets_sum: f64, start: i64, at: i64) -> f32 { + self.compose_with(program, offsets_sum, |cell| curve_toward(cell, start, at)) + } + + /// The composed value at node time `time`. + fn value_at(&self, program: &automation::Program, offsets_sum: f64, time: i64) -> f32 { + self.compose_with(program, offsets_sum, |cell| { + automation::value_at(cell.segments(), time) + }) + } + + /// The output's next event of the block begun on the walk, as a cell event whose value is + /// the composed fader dB. + fn next_event( + &mut self, + program: &automation::Program, + offsets_sum: f64, + ) -> Option { + let compose = self.composition(offsets_sum); + self.walk + .next_event(program, compose) + .map(|event| automation::Event { + offset: event.offset, + value: event.value, + ramp: event.ramp, + kind: event.kind, + held: event.held, + }) + } + + /// Apply `apply` to every cursor's state: the single cell's rule on each of the lane's curves. + fn each_state(&mut self, mut apply: impl FnMut(&mut automation::CellState)) { + for cursor in self.walk.cursors_mut() { + apply(cursor.state_mut()); + } + } + + /// Set each cursor's jump length for the block: the fader row's for the own cursor, the VCA + /// `offset_db` row's for the VCA cursors (#1655, `block_jumps`). + fn set_jumps(&mut self, fader: u32, vca: u32) { + let own = usize::from(self.own_curve); + for (index, cursor) in self.walk.cursors_mut().iter_mut().enumerate() { + cursor.set_jump(if index < own { fader } else { vca }); + } + } +} + +impl MuteComposite { + /// `own || v_1 || ... || v_m` of the heard values. + fn composition(heard: automation::Heard<'_>) -> bool { + (0..heard.len()).any(|index| curve_mutes(heard.value(index))) + } + + /// Whether some curve mutes at node time `time`. + fn mutes_at(&self, program: &automation::Program, time: i64) -> bool { + self.walk.cursors().iter().any(|cursor| { + program.cell(cursor.cell() as usize).map_or_else( + || curve_mutes(cursor.heard()), + |cell| curve_mutes(automation::value_at(cell.segments(), time)), + ) + }) + } + + /// Set each cursor's jump length for the block: the mute row's for the own cursor, the VCA + /// `mute` row's for the VCA cursors. + fn set_jumps(&mut self, mute: u32, vca: u32) { + let own = usize::from(self.own_curve); + for (index, cursor) in self.walk.cursors_mut().iter_mut().enumerate() { + cursor.set_jump(if index < own { mute } else { vca }); + } + } +} + +impl FaderCurve { + /// #1648 D2, at a plan swap: take the carried channel's event state (its cell's, or a composed + /// curve's output and, with one layout, its cursors). A composed channel carried from a lone + /// cell (a gained VCA curve) takes the cell's ramp in flight and no target; a lone cell + /// carried from a composed channel (a lost VCA curve) starts fresh. Every such change of + /// shape repositions (the reaching VCA tables differ, so the join continues no cell). + /// Allocation-free. + fn carry(&mut self, from: &Self) { + match (self, from) { + (Self::Cell { state, .. }, Self::Cell { state: carried, .. }) => *state = *carried, + (Self::Composed(curve), Self::Composed(carried)) => { + if curve.own_curve == carried.own_curve { + carry_cursors(&mut curve.walk, &carried.walk); + } + curve + .walk + .set_output(*carried.walk.output_state(), carried.walk.target()); + // The `own_db` the stage heard; the successor's own records move it, and an + // unequal one with none takes the adoption rule (as `S`, `read_offsets`). + curve.adopted_own_db = Some(curve.own_db); + curve.own_db = carried.own_db; + } + (Self::Composed(curve), Self::Cell { state: carried, .. }) => { + curve.walk.set_output(*carried, None); + } + (Self::Cell { state, .. }, Self::Composed(_)) => { + *state = automation::CellState::new(); + } + } + } +} + +impl MuteCurve { + /// #1651 T4, at a plan swap: take the carried channel's event state, by the rule of + /// [`FaderCurve::carry`]. Allocation-free. + fn carry(&mut self, from: &Self) { + match (self, from) { + (Self::Cell { state, .. }, Self::Cell { state: carried, .. }) => *state = *carried, + (Self::Composed(curve), Self::Composed(carried)) => { + if curve.own_curve == carried.own_curve { + carry_cursors(&mut curve.walk, &carried.walk); + } + curve + .walk + .set_output(*carried.walk.output_state(), carried.walk.target()); + } + (Self::Composed(curve), Self::Cell { state: carried, .. }) => { + curve + .walk + .set_output(*carried, carried.target().map(curve_mutes)); + } + (Self::Cell { state, .. }, Self::Composed(carried)) => { + *state = automation::CellState::new(); + if let Some(muted) = carried.walk.target() { + state.seed_target(f32::from(u8::from(muted))); + } + } + } + } + + /// The curve mute the stage has heard (the output's current target). + fn heard(&self) -> bool { + match self { + Self::Cell { state, .. } => state.target().is_some_and(curve_mutes), + Self::Composed(curve) => curve.walk.target() == Some(true), + } + } + + /// Whether the curve mutes at node time `time` (`None` when a cell is not the program's). + fn mutes_at(&self, program: &automation::Program, time: i64) -> Option { + match self { + Self::Cell { cell, .. } => program + .cell(*cell as usize) + .map(|program| curve_mutes(automation::value_at(program.segments(), time))), + Self::Composed(curve) => Some(curve.mutes_at(program, time)), + } + } +} +// REALTIME_POLICY_END + /// What an automated channel's first block after a plan swap owes (#1648 D2). Set by /// [`LaneFaderAutomation::adopt`] at the swap block (and raised to `Compare` by the first block's /// offsets reads), taken by the lane's next block, which knows the node time. Ordered by strength: @@ -835,12 +1199,11 @@ struct HeldRampObligation { /// (#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)] +#[derive(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 channel's curve: its own cell, or the composed curve of a mute event lane an automated + /// VCA `mute` lane reaches (#1655 V2). + curve: MuteCurve, /// 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, @@ -875,6 +1238,10 @@ struct LaneFaderAutomation { /// 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 VCA rows' jump lengths of that block, `[offset_db, mute]` (`LiveRamps::for_row` of + /// `VcaOffset` and `VcaMute`, #1655): a composed channel's VCA cursors take them; `None` + /// exactly when [`Self::fader_jump`] is. + vca_jumps: Option<[u32; 2]>, /// 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, @@ -882,48 +1249,53 @@ struct LaneFaderAutomation { impl LaneFaderAutomation { /// The lane's automation, from its prepared cells, sharing each fader channel's offsets cell. - /// Control side. - fn new(program: &Arc, strip: &PreparedStripAutomation) -> Self { - let cells = &strip.fader; - Self { + /// Control side: a composed channel allocates its cursor table (#1655 V2). + /// + /// # Errors + /// + /// The [`automation::ComposeError`] of a composed channel's table: unreachable for a plan's + /// own cells (each names a cell of `program`, and a composed channel has at least one), and + /// [`automation::ComposeError::Allocation`] when the table cannot be allocated. + fn new( + program: &Arc, + strip: &PreparedStripAutomation, + ) -> Result { + let mut mute = [None, None]; + for (slot, cell) in mute.iter_mut().zip(&strip.mute) { + let Some(cell) = cell else { continue }; + *slot = Some(AutomatedMuteCell { + curve: MuteCurve::prepared(program, cell)?, + terms: cell.terms, + reposition: false, + }); + } + let mut channels = [None, None]; + for (slot, cell) in channels.iter_mut().zip(&strip.fader) { + let Some(cell) = cell else { continue }; + *slot = Some(AutomatedFaderCell { + curve: FaderCurve::prepared(program, cell)?, + offsets: Arc::clone(&cell.offsets), + offsets_reader: CellReader::new(), + offsets_dirty: DirtyReader::new(), + offsets_sum: cell.offsets_sum, + offsets_ramp: cell.offsets_ramp, + obligation: HeldRampObligation::default(), + adoption: Adoption::None, + carried_record: None, + adopted_sum: None, + }); + } + Ok(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, - reposition: false, - } - }) - }), - channels: core::array::from_fn(|channel| { - cells[channel].as_ref().map(|cell| { - let mut state = automation::CellState::new(); - state.seed_target(cell.value); - AutomatedFaderCell { - cell: cell.cell, - state, - offsets: Arc::clone(&cell.offsets), - offsets_reader: CellReader::new(), - offsets_dirty: DirtyReader::new(), - offsets_sum: cell.offsets_sum, - offsets_ramp: cell.offsets_ramp, - obligation: HeldRampObligation::default(), - adoption: Adoption::None, - carried_record: None, - adopted_sum: None, - } - }) - }), + mute, + channels, arrival: 0, span: None, fader_jump: None, mute_jump: None, + vca_jumps: None, fresh: true, - } + }) } // REALTIME_POLICY_BEGIN @@ -969,7 +1341,7 @@ impl LaneFaderAutomation { }); cell.reposition = match carried { Some((carried, carried_arrival)) => { - cell.state = carried.state; + cell.curve.carry(&carried.curve); cell.terms = carried.terms; continued_cells & (1 << (MUTE_CONTINUED_SHIFT + channel)) == 0 || carried_arrival != arrival @@ -991,7 +1363,7 @@ impl LaneFaderAutomation { }); cell.adoption = match carried { Some((carried, carried_arrival)) => { - cell.state = carried.state; + cell.curve.carry(&carried.curve); cell.obligation = carried.obligation; cell.adopted_sum = Some(cell.offsets_sum); cell.offsets_sum = carried.offsets_sum; @@ -1045,6 +1417,7 @@ impl LaneFaderAutomation { self.span = time.node_span(self.arrival); self.fader_jump = jumps.map(|jumps| jumps.fader); self.mute_jump = jumps.map(|jumps| jumps.mute); + self.vca_jumps = jumps.map(|jumps| [jumps.vca_offset, jumps.vca_mute]); } /// #1651 D1, the drain branch: a mute value the block drain read for channel `channel`, whose @@ -1070,7 +1443,7 @@ impl LaneFaderAutomation { return; }; cell.terms = terms; - let heard = cell.state.target().is_some_and(curve_mutes) || terms; + let heard = cell.curve.heard() || terms; let stage_muted = stage.muted(channel); if heard == stage_muted { return; @@ -1085,10 +1458,8 @@ impl LaneFaderAutomation { // 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 - }), + .and_then(|time| cell.curve.mutes_at(&self.program, time)) + .map(|muted| muted || terms), _ => None, }; let muted = at_node.unwrap_or(heard); @@ -1189,6 +1560,19 @@ impl LaneFaderAutomation { cell.offsets_sum = sum; cell.adoption = cell.adoption.max(Adoption::Compare); } + // #1655 D3: a composed channel's `own_db` takes the same rule as its `S`. + if let FaderCurve::Composed(curve) = &mut cell.curve + && let Some(prepared) = curve.adopted_own_db.take() + && own.is_none() + && prepared.to_bits() != curve.own_db.to_bits() + { + debug_assert!( + superseded.is_none(), + "the adoption rule stands in for a live own_db record" + ); + curve.own_db = prepared; + cell.adoption = cell.adoption.max(Adoption::Compare); + } } } // REALTIME_POLICY_END @@ -1295,27 +1679,58 @@ impl AutomatedFaderCell { self.offsets_ramp = ramp; let completion = controls::FADER_CELL_RULE.completion; let start = span.and_then(|span| node_time_at(span, 0)); - let value = match (start, program.cell(self.cell as usize)) { - (Some(start), Some(cell)) => curve_toward( - &cell, - start, - automation::completion_of(completion, start, ramp), - ), - (Some(_), None) => return false, - (None, _) => match self.state.target() { - Some(value) => value, - None => return true, - }, - }; let compose = |value, sum| builtins::checked_fader_gain(session::vca_compose_db(value, sum)).ok(); - let Some(gain) = compose(value, self.offsets_sum) else { - return false; + // `value` is the channel's new target (a cell's curve value, a composed channel's fader + // dB), `gain` its gain, `current` the gain of the target the lane heard. + let (value, gain, current) = match &mut self.curve { + FaderCurve::Cell { cell, state } => { + let value = match (start, program.cell(*cell as usize)) { + (Some(start), Some(cell)) => curve_toward( + &cell, + start, + automation::completion_of(completion, start, ramp), + ), + (Some(_), None) => return false, + (None, _) => match state.target() { + Some(value) => value, + None => return true, + }, + }; + let Some(gain) = compose(value, self.offsets_sum) else { + return false; + }; + let current = state.target().and_then(|target| compose(target, heard)); + (value, gain, current) + } + FaderCurve::Composed(curve) => { + // #1655 D3: a record of an event lane whose own fader is not automated carries + // the member's `own_db`, composed with the new `S` and the curves. + if let (false, Some(own_db)) = (curve.own_curve, record.own_db) { + curve.own_db = own_db; + } + let value = match start { + Some(start) => curve.toward( + program, + self.offsets_sum, + start, + automation::completion_of(completion, start, ramp), + ), + None => { + let compose = curve.composition(self.offsets_sum); + curve.walk.compose_heard(compose) + } + }; + let Some(gain) = builtins::checked_fader_gain(value).ok() else { + return false; + }; + let current = curve + .walk + .target() + .and_then(|target| builtins::checked_fader_gain(target).ok()); + (value, gain, current) + } }; - let current = self - .state - .target() - .and_then(|target| compose(target, heard)); if current.map(f32::to_bits) == Some(gain.to_bits()) { return true; } @@ -1330,11 +1745,26 @@ impl AutomatedFaderCell { if stage.gain_held(channel) { let remaining = stage.ramp_remaining(channel); let applied = stage.apply(channel, operation); - self.state = automation::CellState::new(); - if let (Some(last), Some(span)) = (remaining.checked_sub(1), span) - && let Some(end) = node_time_at(span, u64::from(last)) - { - self.state.hold_until(end); + let end = remaining + .checked_sub(1) + .zip(span) + .and_then(|(last, span)| node_time_at(span, u64::from(last))); + match &mut self.curve { + FaderCurve::Cell { state, .. } => { + *state = automation::CellState::new(); + if let Some(end) = end { + state.hold_until(end); + } + } + FaderCurve::Composed(curve) => { + curve.walk.clear_target(); + curve.each_state(|state| { + *state = automation::CellState::new(); + if let Some(end) = end { + state.hold_until(end); + } + }); + } } return applied; } @@ -1345,13 +1775,24 @@ impl AutomatedFaderCell { // block's R17-10 release must not cut it (#1647 U2 fold 3, F1). A successor's carried // record takes this same path at its first block, with the carried obligation. self.obligation.held_through = false; - self.state.seed_target(value); + let hold = start.map(|start| automation::completion_of(completion, start, ramp)); // A newer live move keeps its own length: a jump an adoption deferred to the end of the // ramp this one replaces is dropped (#1711 D1). - self.state.clear_deferred_jump(); - if let Some(start) = start { - self.state - .hold_until(automation::completion_of(completion, start, ramp)); + match &mut self.curve { + FaderCurve::Cell { state, .. } => { + state.seed_target(value); + state.clear_deferred_jump(); + if let Some(hold) = hold { + state.hold_until(hold); + } + } + FaderCurve::Composed(curve) => { + curve.walk.seed_target(value); + curve.walk.clear_deferred_jump(); + if let Some(hold) = hold { + curve.each_state(|state| state.hold_until(hold)); + } + } } true } @@ -1392,13 +1833,18 @@ fn span_time(start: i64, step: Option<(u32, i64)>, offset: u64) -> Option { base.checked_add(i64::try_from(offset - from).ok()?) } -/// The fader and mute rows' jump lengths of one block (#1639 D2, #1651 D1). +/// The fader, mute and VCA rows' jump lengths of one block (#1639 D2, #1651 D1, #1655). #[derive(Clone, Copy, Debug)] struct BlockJumps { /// `LiveRamps::for_row(Fader)`. fader: u32, /// `LiveRamps::for_row(Mute)`. mute: u32, + /// `LiveRamps::for_row(VcaOffset)`: a composed channel's VCA offset cursors (resolved by + /// row, not assumed equal to the fader's). + vca_offset: u32, + /// `LiveRamps::for_row(VcaMute)`. + vca_mute: u32, } /// The fader and mute rows' jump lengths of a block (#1639 D2, #1647a departure 7, #1651 D1), @@ -1414,6 +1860,8 @@ fn block_jumps(time: &GraphBlockTime<'_>) -> Option { BlockJumps { fader: ramps.for_row(effect_contract::control::LiveRampRow::Fader), mute: ramps.for_row(effect_contract::control::LiveRampRow::Mute), + vca_offset: ramps.for_row(effect_contract::control::LiveRampRow::VcaOffset), + vca_mute: ramps.for_row(effect_contract::control::LiveRampRow::VcaMute), } }) } @@ -1468,12 +1916,10 @@ 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 later pieces. - program: automation::CellProgram<'a>, + /// The block's jump length of a lone cell, to build the later pieces. jump: u32, - /// 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 channel's events: a lone cell's or a composed curve's (#1655 V2). + walk: ChannelEvents<'a>, /// The block offset of the current piece's first sample. base: u32, /// The block offset of the current piece's last sample. @@ -1500,18 +1946,46 @@ struct FaderCellCursor<'a> { frames: u32, } +/// One fader channel's events in a block (#1655 V2): a lone cell's, piece by piece, or a composed +/// curve's, whose walk is begun on each piece. +enum ChannelEvents<'a> { + Cell { + /// The cell's program. + program: automation::CellProgram<'a>, + /// 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>, + }, + Composed { + /// The plan's program. + program: &'a automation::Program, + /// The composed curve; its walk holds every cursor's state for the next piece. + curve: &'a mut FaderComposite, + }, +} + impl FaderCellCursor<'_> { /// Pull the current piece's next event, rebased to the block. fn pull(&mut self) { let base = self.base; - self.next = self - .events - .as_mut() - .and_then(Iterator::next) - .map(|event| automation::Event { - offset: event.offset.saturating_add(base), - ..event - }); + let sum = self.offsets_sum; + let next = match &mut self.walk { + ChannelEvents::Cell { events, .. } => events.as_mut().and_then(Iterator::next), + ChannelEvents::Composed { program, curve } => curve.next_event(program, sum), + }; + self.next = next.map(|event| automation::Event { + offset: event.offset.saturating_add(base), + ..event + }); + } + + /// The gain of event value `value`: a lone cell's curve value composed with `S`, a composed + /// curve's fader dB as it is; `None` when it has no fader gain (unreachable). + fn gain(&self, value: f32) -> Option { + match self.walk { + ChannelEvents::Cell { .. } => FaderEventDriver::gain(value, self.offsets_sum), + ChannelEvents::Composed { .. } => builtins::checked_fader_gain(value).ok(), + } } /// The offset of the cursor's next operation: its next event, or the cut at `E` once the @@ -1522,14 +1996,9 @@ impl FaderCellCursor<'_> { .or_else(|| self.end.map(|end| end.at)) } - /// 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. + /// The last offset of a piece that starts at `from` ([`piece_last`]). 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) + piece_last(self.end, self.mute_at, self.frames, from) } /// End the piece at the cut. With `moving`, the cell first lands at `E` as a seek in @@ -1551,17 +2020,31 @@ impl FaderCellCursor<'_> { 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 + let sum = self.offsets_sum; + match &mut self.walk { + ChannelEvents::Cell { program, events } => { + let state = events.take()?.into_state(); + let mut landing_events = automation::CellEvents::new( + *program, + state, + span, + automation::SeekMode::Ramp, + self.jump, + ); + let landing = landing_events.next(); + *events = Some(landing_events); + landing + } + ChannelEvents::Composed { program, curve } => { + // Every cursor lands (R17-9 on each curve), and the output lands over the longest + // of their jumps (R19-4). + curve.walk.start(program, span, automation::SeekMode::Ramp); + let landing = curve.next_event(program, sum); + // The one-sample span holds no other offset: the walk ends here. + while curve.next_event(program, sum).is_some() {} + landing + } + } } /// Start the next piece at block offset `from` (the current piece is spent), through @@ -1576,10 +2059,17 @@ impl FaderCellCursor<'_> { 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() - { - release_lane_hold(events.into_state(), release); + if let Some(release) = self.release.take() { + match &mut self.walk { + ChannelEvents::Cell { events, .. } => { + if let Some(events) = events.take() { + release_lane_hold(events.into_state(), release); + } + } + ChannelEvents::Composed { curve, .. } => { + curve.each_state(|state| release_lane_hold(state, release)); + } + } } return false; } @@ -1588,22 +2078,35 @@ impl FaderCellCursor<'_> { debug_assert!(false, "a piece of a checked block is a valid span"); return false; }; - let Some(events) = self.events.take() else { - return false; - }; + let release = self.release.take(); + match &mut self.walk { + ChannelEvents::Cell { program, events } => { + let Some(spent) = events.take() else { + return false; + }; + let state = spent.into_state(); + if let Some(release) = release { + release_lane_hold(state, release); + } + *events = Some(automation::CellEvents::new( + *program, + state, + span, + controls::FADER_CELL_RULE.seek, + self.jump, + )); + } + ChannelEvents::Composed { program, curve } => { + if let Some(release) = release { + curve.each_state(|state| release_lane_hold(state, release)); + } + curve + .walk + .start(program, span, controls::FADER_CELL_RULE.seek); + } + } self.base = from; self.last = last; - let state = events.into_state(); - if let Some(release) = self.release.take() { - release_lane_hold(state, release); - } - self.events = Some(automation::CellEvents::new( - self.program, - state, - span, - controls::FADER_CELL_RULE.seek, - self.jump, - )); self.pull(); true } @@ -1668,6 +2171,44 @@ impl FaderCellCursor<'_> { } } +/// The last offset of a piece that starts at `from` in a block of `frames` samples: the cut at +/// `E` (`end`) or the channel's next mute event (`mute_at`), whichever comes first, else the +/// block's last sample. +fn piece_last(end: Option, mute_at: Option, frames: u32, from: u32) -> u32 { + [end.map(|end| end.at), mute_at] + .into_iter() + .flatten() + .filter(|&at| at >= from) + .fold(frames.saturating_sub(1), u32::min) +} + +/// A fader channel as the driver placed it at a block's entry, before its first piece. +enum Placed<'a> { + Cell { + program: automation::CellProgram<'a>, + state: &'a mut automation::CellState, + }, + Composed(&'a mut FaderComposite), +} + +impl Placed<'_> { + /// R17-10: release the hold of the ramp a live write ended at the block's entry (`release`, + /// the node time before the block's first sample) and drop a jump deferred to its end: on a + /// lone cell, or on every cursor of a composed curve. + fn release_at_entry(&mut self, release: i64) { + match self { + Self::Cell { state, .. } => { + state.hold_until(release); + state.clear_deferred_jump(); + } + Self::Composed(curve) => { + curve.each_state(|state| state.hold_until(release)); + curve.walk.clear_deferred_jump(); + } + } + } +} + /// 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). @@ -1732,7 +2273,7 @@ struct MuteCellCursor<'a> { /// 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>, + events: MuteEvents<'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 @@ -1740,10 +2281,32 @@ struct MuteCellCursor<'a> { pushed: Option<(bool, u32)>, } +/// One mute channel's events in a block (#1655 V2): a lone cell's or a composed curve's, whose +/// value is `1.0` when the curves mute. +enum MuteEvents<'a> { + Cell(automation::CellEvents<'a, 'a>), + Composed { + program: &'a automation::Program, + curve: &'a mut MuteComposite, + }, +} + impl MuteCellCursor<'_> { /// Pull the cell's next event. fn pull(&mut self) { - self.next = self.events.next(); + self.next = match &mut self.events { + MuteEvents::Cell(events) => events.next(), + MuteEvents::Composed { program, curve } => curve + .walk + .next_event(program, MuteComposite::composition) + .map(|event| automation::Event { + offset: event.offset, + value: f32::from(u8::from(event.value)), + ramp: event.ramp, + kind: event.kind, + held: event.held, + }), + }; } } @@ -1784,11 +2347,14 @@ impl<'a> FaderEventDriver<'a> { span, fader_jump, mute_jump, + vca_jumps, fresh, .. } = automation; let first_block = core::mem::replace(fresh, false); - let (Some(span), Some(jump), Some(mute_jump)) = (*span, *fader_jump, *mute_jump) else { + let (Some(span), Some(jump), Some(mute_jump), Some([vca_offset_jump, vca_mute_jump])) = + (*span, *fader_jump, *mute_jump, *vca_jumps) + else { continue; }; let length = i64::from(frames); @@ -1809,8 +2375,7 @@ impl<'a> FaderEventDriver<'a> { let lane_mutes = mutes_used; for (channel, cell) in mute.iter_mut().enumerate() { let Some(AutomatedMuteCell { - cell, - state, + curve, terms, reposition, }) = cell.as_mut() @@ -1819,42 +2384,77 @@ impl<'a> FaderEventDriver<'a> { }; 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; - }; // #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). + // lands instead (the seek sets the lane exactly where it applies). A composed + // curve (#1655 D4) places each cursor in its own cell. 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(|| { + let mut step_with = |differs: bool| { + span.step + .map(|step| (step.offset, step.timeline)) + .or_else(|| { + if adopted { + mode = automation::SeekMode::Ramp; + return Some((0, span.start)); + } + (first_block && differs).then_some((0, span.start)) + }) + }; + let muted = hold(lane, channel).is_some_and(|hold| hold.muted); + let events = match curve { + MuteCurve::Cell { cell, state } => { + // 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; + }; if adopted { - mode = automation::SeekMode::Ramp; - return Some((0, span.start)); + state.reposition(&cell_program, 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)) - }); - let muted = hold(lane, channel).is_some_and(|hold| hold.muted); + let step = + step_with(state.target().map(f32::to_bits) != Some(value.to_bits())); + let Ok(node) = automation::NodeSpan::new(span.start, step, frames) else { + refused(); + continue; + }; + MuteEvents::Cell(automation::CellEvents::new( + cell_program, + state, + node, + mode, + mute_jump, + )) + } + MuteCurve::Composed(curve) => { + let curve: &'a mut MuteComposite = curve; + if adopted { + for cursor in curve.walk.cursors_mut() { + if let Some(cell) = program.cell(cursor.cell() as usize) { + cursor.state_mut().reposition(&cell, span.start); + } + } + } + curve.set_jumps(mute_jump, vca_mute_jump); + let value = curve.mutes_at(program, span.start); + let step = step_with(curve.walk.target() != Some(value)); + let Ok(node) = automation::NodeSpan::new(span.start, step, frames) else { + refused(); + continue; + }; + curve.walk.start(program, node, mode); + MuteEvents::Composed { program, curve } + } + }; // 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 { + let Some(slot) = driver.mutes.get_mut(mutes_used) else { refused(); continue; }; @@ -1863,7 +2463,7 @@ impl<'a> FaderEventDriver<'a> { channel, terms: *terms, muted, - events: automation::CellEvents::new(cell_program, state, node, mode, mute_jump), + events, next: None, pushed: None, }; @@ -1873,8 +2473,7 @@ impl<'a> FaderEventDriver<'a> { } for (channel, cell) in channels.iter_mut().enumerate() { let Some(AutomatedFaderCell { - cell, - state, + curve, offsets_sum, obligation, adoption, @@ -1885,33 +2484,68 @@ impl<'a> FaderEventDriver<'a> { }; let refused = || debug_assert!(false, "an automated fader cell is refused at render"); - // Unreachable: the cell index was prepared from this same program. In release the - // channel is skipped: it emits nothing and the lane holds its gain, no panic. - let Some(cell_program) = program.cell(*cell as usize) else { - refused(); - continue; + let hold_now = hold(lane, channel); + let owed = core::mem::replace(adoption, Adoption::None); + let first_step = |differs: bool| { + span.step + .map(|step| (step.offset, step.timeline)) + .or_else(|| (first_block && differs).then_some((0, span.start))) }; - let mut step = span - .step - .map(|step| (step.offset, step.timeline)) - .or_else(|| { + // The channel's first-block step and the adoption rule (#1648 D2, #1711): one + // rule on a lone cell and on a composed curve (#1655 V2). + let (mut step, landing, mut placed) = match curve { + FaderCurve::Cell { cell, state } => { + // Unreachable: the cell index was prepared from this same program. In + // release the channel is skipped: it emits nothing and the lane holds its + // gain, no panic. + let Some(cell_program) = program.cell(*cell as usize) else { + refused(); + continue; + }; 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 hold_now = hold(lane, channel); + let step = + first_step(state.target().map(f32::to_bits) != Some(value.to_bits())); + let landing = settle_adoption( + owed, + span, + hold_now, + &mut CellAdoption { + program: &cell_program, + state: &mut *state, + offsets_sum: *offsets_sum, + }, + ); + ( + step, + landing, + Placed::Cell { + program: cell_program, + state, + }, + ) + } + FaderCurve::Composed(curve) => { + let curve: &'a mut FaderComposite = curve; + curve.set_jumps(jump, vca_offset_jump); + let value = curve.value_at(program, *offsets_sum, span.start); + let step = first_step( + curve.walk.target().map(f32::to_bits) != Some(value.to_bits()), + ); + let landing = settle_adoption( + owed, + span, + hold_now, + &mut ComposedAdoption { + program, + curve: &mut *curve, + offsets_sum: *offsets_sum, + }, + ); + (step, landing, Placed::Composed(curve)) + } + }; let mut mode = controls::FADER_CELL_RULE.seek; - if settle_adoption( - core::mem::replace(adoption, Adoption::None), - AdoptionBlock { - program: &cell_program, - span, - offsets_sum: *offsets_sum, - hold: hold_now, - }, - state, - ) && step.is_none() - { + if landing && step.is_none() { // The adoption jump (#1648 D2): a ramped landing at the block's first sample // over the block's jump length, to the curve at the jump's completion. step = Some((0, span.start)); @@ -1923,8 +2557,7 @@ impl<'a> FaderEventDriver<'a> { // block, so the cell's hold of the ramp it ended is released here, owed or // not: its grid moves the lane from this block's first sample on (and a jump // deferred to that ramp's end is dropped with it, #1711 D1). - state.hold_until(first.saturating_sub(1)); - state.clear_deferred_jump(); + placed.release_at_entry(first.saturating_sub(1)); } let end = at.map(|at| hold_end(span.start, step, at)); // The channel's first mute event (#1651 T3b): the first piece ends there. @@ -1946,40 +2579,44 @@ impl<'a> FaderEventDriver<'a> { refused(); continue; }; + let end = end.flatten(); + let last = piece_last(end, mute_at, frames, 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; + }; + let walk = match placed { + Placed::Cell { program, state } => ChannelEvents::Cell { + program, + events: Some(automation::CellEvents::new( + program, state, node, mode, jump, + )), + }, + Placed::Composed(curve) => { + curve.walk.start(program, node, mode); + ChannelEvents::Composed { program, curve } + } + }; let mut cursor = FaderCellCursor { lane, channel, offsets_sum: *offsets_sum, - program: cell_program, jump, - events: None, + walk, base: 0, - last: 0, + last, next: None, obligation, free_from, - end: end.flatten(), + end, mute_at, release: None, start: span.start, step, frames, }; - 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, - )); cursor.pull(); *slot = Some(cursor); used += 1; @@ -1995,10 +2632,13 @@ impl<'a> FaderEventDriver<'a> { builtins::checked_fader_gain(session::vca_compose_db(value, offsets_sum)).ok() } - /// The fader operation of `event` on a channel whose offset sum is `offsets_sum`, or `None` - /// when the composed value has no fader gain. - fn operation(event: automation::Event, offsets_sum: f64) -> Option { - let gain = Self::gain(event.value, offsets_sum)?; + /// The fader operation of `event` on `cursor`'s channel, or `None` when its value has no fader + /// gain ([`FaderCellCursor::gain`]). + fn operation( + cursor: &FaderCellCursor<'_>, + event: automation::Event, + ) -> Option { + let gain = cursor.gain(event.value)?; Some(if event.held { builtins::FaderOperation::RememberGain { gain } } else { @@ -2087,12 +2727,115 @@ impl HeldRampObligation { } } -/// What the adoption rule of one automated channel reads at its first block after a plan swap. -struct AdoptionBlock<'p, 'a> { +/// The curve of one adopted fader channel, as the adoption rule reads and moves it +/// ([`settle_adoption`]): a lone cell ([`CellAdoption`]) or a composed curve +/// ([`ComposedAdoption`], #1655 V2), so both take one rule. +trait AdoptedCurve { + /// Place the curve at node time `t` in its own table(s) (a reposition, #1637 D4). + fn reposition(&mut self, t: i64); + /// Report a ramp of the stage that completes at node time `end` (the grid holds inside it). + fn hold_until(&mut self, end: i64); + /// Whether a curve has a discontinuity (a step of its own) at node time `t`. + fn steps_at(&self, t: i64) -> bool; + /// The channel's value toward a ramp started at `start` and completing at `at` + /// ([`curve_toward`]; a composed curve composes each). + fn toward(&self, start: i64, at: i64) -> f32; + /// The fader gain of the channel's value `value`. + fn gain(&self, value: f32) -> Option; + /// A repositioned curve whose comparison did not differ takes `value`, the channel's value + /// toward `start..at`, as its current target (the stage already holds its gain). + fn seed(&mut self, start: i64, at: i64, value: f32); + /// Defer the curve's jump to node time `at` ([`automation::CellState::defer_jump`]). + fn defer_jump(&mut self, at: i64); +} + +/// A lone cell's curve under the adoption rule. +struct CellAdoption<'p, 'a, 's> { program: &'p automation::CellProgram<'a>, - span: TimelineSpan, + state: &'s mut automation::CellState, offsets_sum: f64, - hold: Option, +} + +/// A composed curve under the adoption rule (#1655 V2): each of the cell rule's moves acts on +/// every cursor. +struct ComposedAdoption<'p, 's> { + program: &'p automation::Program, + curve: &'s mut FaderComposite, + offsets_sum: f64, +} + +impl AdoptedCurve for CellAdoption<'_, '_, '_> { + fn reposition(&mut self, t: i64) { + self.state.reposition(self.program, t); + } + fn hold_until(&mut self, end: i64) { + self.state.hold_until(end); + } + fn steps_at(&self, t: i64) -> bool { + self.program.discontinuities().binary_search(&t).is_ok() + } + fn toward(&self, start: i64, at: i64) -> f32 { + curve_toward(self.program, start, at) + } + fn gain(&self, value: f32) -> Option { + FaderEventDriver::gain(value, self.offsets_sum) + } + fn seed(&mut self, _start: i64, _at: i64, value: f32) { + self.state.seed_target(value); + } + fn defer_jump(&mut self, at: i64) { + self.state.defer_jump(at); + } +} + +impl AdoptedCurve for ComposedAdoption<'_, '_> { + /// Each cursor is placed afresh in its own cell at `t` and hears its curve there: with no + /// target of its own, a deferred jump fires and a landing lands on every curve, so the + /// composed value is the successor's tables' wherever the comparison differs (the cursors' + /// carried targets belong to the tables they were built on). + fn reposition(&mut self, t: i64) { + let program = self.program; + for cursor in self.curve.walk.cursors_mut() { + let Some(cell) = program.cell(cursor.cell() as usize) else { + continue; + }; + let state = cursor.state_mut(); + *state = automation::CellState::new(); + state.reposition(&cell, t); + cursor.set_heard(automation::value_at(cell.segments(), t)); + } + } + fn hold_until(&mut self, end: i64) { + self.curve.each_state(|state| state.hold_until(end)); + } + fn steps_at(&self, t: i64) -> bool { + self.curve.walk.cursors().iter().any(|cursor| { + self.program + .cell(cursor.cell() as usize) + .is_some_and(|cell| cell.discontinuities().binary_search(&t).is_ok()) + }) + } + fn toward(&self, start: i64, at: i64) -> f32 { + self.curve.toward(self.program, self.offsets_sum, start, at) + } + fn gain(&self, value: f32) -> Option { + builtins::checked_fader_gain(value).ok() + } + fn seed(&mut self, start: i64, at: i64, value: f32) { + let program = self.program; + for cursor in self.curve.walk.cursors_mut() { + let Some(cell) = program.cell(cursor.cell() as usize) else { + continue; + }; + let heard = curve_toward(&cell, start, at); + cursor.state_mut().seed_target(heard); + cursor.set_heard(heard); + } + self.curve.walk.seed_target(value); + } + fn defer_jump(&mut self, at: i64) { + self.curve.walk.defer_jump(at); + } } /// #1648 D2, at an adopted channel's first block: settle what [`LaneFaderAutomation::adopt`] left @@ -2122,15 +2865,10 @@ struct AdoptionBlock<'p, 'a> { /// caller's step wins), and a seek landing drops a deferred jump. fn settle_adoption( adoption: Adoption, - block: AdoptionBlock<'_, '_>, - state: &mut automation::CellState, + span: TimelineSpan, + hold: Option, + curve: &mut impl AdoptedCurve, ) -> bool { - let AdoptionBlock { - program, - span, - offsets_sum, - hold, - } = block; if adoption == Adoption::None { return false; } @@ -2140,7 +2878,7 @@ fn settle_adoption( // The free stage's ramp's first free sample, where a moved target's jump is deferred to. let mut free = None; if adoption == Adoption::Reposition { - state.reposition(program, span.start); + curve.reposition(span.start); if let Some(ChannelHold { held, remaining, .. }) = hold @@ -2149,35 +2887,31 @@ fn settle_adoption( { // A held stage: the held ramp again (batch 13b). A free stage: the ramp it runs is the // cell's own and `reposition` dropped its hold, so the cell holds it again. - state.hold_until(end); + curve.hold_until(end); // A step of the cell's own at the block's first sample jumps over `J` there, as the // cell does at any discontinuity (and as a plan prepared with this table does), so // the curve is compared at the block's first sample (B17-R13): `curve_toward` looks // only past `start` and would skip it. - if !held - && program - .discontinuities() - .binary_search(&span.start) - .is_err() - { + if !held && !curve.steps_at(span.start) { at = end; free = node_time_at(span, u64::from(remaining)); } } } - let value = curve_toward(program, span.start, at); - let differs = FaderEventDriver::gain(value, offsets_sum) + let value = curve.toward(span.start, at); + let differs = curve + .gain(value) .zip(hold.and_then(|hold| hold.stage_gain)) .is_some_and(|(gain, stage)| gain.to_bits() != stage.to_bits()); if !differs { if adoption == Adoption::Reposition { - state.seed_target(value); + curve.seed(span.start, at, value); } return false; } match free { Some(free) => { - state.defer_jump(free); + curve.defer_jump(free); false } None => true, @@ -2232,7 +2966,7 @@ impl builtins::FaderOperationSource for FaderEventDriver<'_> { // at `E`. let moving = end.is_some() && cursor.obligation.owed && !set_at_end; let mut push = |cursor: &FaderCellCursor<'_>, event: automation::Event| { - let pushed = Self::operation(event, cursor.offsets_sum) + let pushed = Self::operation(cursor, event) .is_some_and(|operation| sink.push(cursor.lane, selector, operation).is_ok()); // Unreachable refusal: one cursor per lane and channel, and constraint 7 with // R17-8 push one gain operation per cursor and offset (no `Duplicate`); the lane @@ -2369,7 +3103,8 @@ pub struct TestOnlyFaderEvent { pub kind: automation::EventKind, /// A held grid event (a `RememberGain`). pub held: bool, - /// The curve value, before `S`. + /// The curve value, before `S`; on a composed channel (#1655 V2) the composed fader dB, `S` + /// and the VCA curves included. pub value: f32, /// The ramp length. pub ramp: u32, @@ -2442,16 +3177,44 @@ pub fn test_only_take_fader_events() -> (Vec, bool) { }) } +/// One VCA's cells in the plan's program (#1655 D1), `[row][lane]`: row 0 `offset_db`, row 1 +/// `mute`; lane 0 left, 1 right; `None` for a lane no entry drives. +pub type VcaCells = [[Option; 2]; 2]; + +/// A fader owner's lane automation at bind ([`LaneFaderAutomation::new`]). Control side. +/// +/// # Panics +/// +/// When a composed channel's cursor table cannot be allocated: the failure of any allocation of +/// the bind (as a `Box::new` aborts). The plan's cells are its own program's, and a composed +/// channel has at least one, so the table's other refusals are unreachable. +fn bound_lane_automation( + program: &Arc, + strip: &PreparedStripAutomation, +) -> LaneFaderAutomation { + match LaneFaderAutomation::new(program, strip) { + Ok(automation) => automation, + Err(error) => panic!("a prepared lane binds its automation: {error:?}"), + } +} + /// One automated fader channel as preparation computed it (#1646 D1, D2), with its offsets cell /// (#1649), which the strip's [`FaderCells`] and its fader owner share. #[derive(Clone, Debug)] struct PreparedFaderCell { - /// The cell's index in the program. - cell: u32, - /// The prepared curve value: the entry's first `start_value`, the curve at node time `-a(n)` - /// for every `a(n)` (D2, the hold rule). + /// The index in the program of the lane's own fader cell; `None` on a fader event lane whose + /// own fader is not automated (#1655 D3). + cell: Option, + /// The cells of the automated VCA `offset_db` lanes that reach the lane, in ascending VCA ID + /// (#1655 D1); empty when none does (the lane renders its own cell alone, as before #1655). + vca: Box<[u32]>, + /// The prepared own value: the own entry's first `start_value`, the curve at node time + /// `-a(n)` for every `a(n)` (D2, the hold rule); `own_db` with no own cell. value: f32, - /// The lane's prepared fader dB, `vca_compose_db(value, S)`, composed once inside + /// The member's static fader dB (#1655 D3), the lane's own value with no own cell. + own_db: f32, + /// The lane's prepared fader dB, `vca_compose_db(value, S)` (#1655 D2: `S` and then each + /// reaching VCA curve's first `start_value`), composed once inside /// [`lane::fpenv::CanonicalFpEnv`] (D2). It is the lane's prepared fader parameter, so the /// lowering bakes it through the same `checked_fader_gain` path a static fader takes. fader_db: f32, @@ -2464,13 +3227,19 @@ struct PreparedFaderCell { } /// One automated mute channel as preparation computed it (#1651 D1, batch 18 amendment). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, 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). + /// The index in the program of the lane's own mute cell, after every fader cell; `None` on a + /// mute event lane whose own mute is not automated (#1655 D4). + cell: Option, + /// The cells of the automated VCA `mute` lanes that reach the lane, in ascending VCA ID. + vca: Box<[u32]>, + /// The prepared own 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); `0` with no + /// own cell. value: f32, + /// The prepared curve mute: `own || v_1 || ... || v_m` at the curves' first `start_value`s. + curve_muted: bool, /// `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, @@ -2501,6 +3270,10 @@ struct FaderAutomationPlan { /// cell index of each row, in [`controls::route_automation`]'s order, `None` for a row no /// entry drives. routes: BTreeMap, [Option; 6]>, + /// Each VCA with an automated `offset_db` or `mute` lane (#1655 D1), by VCA ID: the program's + /// cell index of each `[row][lane]` (row 0 `offset_db`, row 1 `mute`), `None` for a lane no + /// entry drives. + vcas: BTreeMap, VcaCells>, } impl FaderAutomationPlan { @@ -2529,9 +3302,9 @@ impl FaderAutomationPlan { return effective; }; [0, 1].map(|lane| { - cells.mute[lane].as_ref().map_or(effective[lane], |cell| { - curve_mutes(cell.value) || cell.terms - }) + cells.mute[lane] + .as_ref() + .map_or(effective[lane], |cell| cell.curve_muted || cell.terms) }) } @@ -2542,6 +3315,47 @@ impl FaderAutomationPlan { .checked_add(self.program_bytes) } + /// The composed channels' bytes among `members` (#1655 R19-6): each composed fader or mute + /// channel's boxed curve and its cursor table (one cursor per curve: the reach pairs), and the + /// largest of those single allocations. + fn composed_bytes<'a>(&self, members: impl Iterator) -> Option<(u64, u64)> { + let sizes = fader_automation_sizes(); + let mut total = 0_u64; + let mut largest = 0_u64; + for cells in members.filter_map(|track| self.strip(track)) { + let faders = cells + .fader + .iter() + .flatten() + .filter(|cell| cell.cell.is_none() || !cell.vca.is_empty()) + .map(|cell| { + ( + sizes.fader_composite_bytes, + usize::from(cell.cell.is_some()) + cell.vca.len(), + ) + }); + let mutes = cells + .mute + .iter() + .flatten() + .filter(|cell| cell.cell.is_none() || !cell.vca.is_empty()) + .map(|cell| { + ( + sizes.mute_composite_bytes, + usize::from(cell.cell.is_some()) + cell.vca.len(), + ) + }); + for (curve, cursors) in faders.chain(mutes) { + let table = sizes + .cursor_bytes + .checked_mul(u64::try_from(cursors).ok()?)?; + total = total.checked_add(curve)?.checked_add(table)?; + largest = largest.max(curve).max(table); + } + } + Some((total, largest)) + } + /// The automated fader channels among `members`: one offsets cell each (#1649). fn automated_channels<'a>(&self, members: impl Iterator) -> Option { members @@ -2567,6 +3381,14 @@ pub struct FaderAutomationSizes { /// program handle of both channels). A fader bank with an automated member holds one per bank /// lane. pub lane_slot_bytes: u64, + /// One composed fader channel's boxed curve (#1655 V2): its output state and `own_db`, beside + /// its cursor table. + pub fader_composite_bytes: u64, + /// One composed mute channel's boxed curve (#1655 V2), beside its cursor table. + pub mute_composite_bytes: u64, + /// One cursor of a composed channel's table: one (member channel, curve) pair's event state + /// (R19-6), `automation::Composed::bytes(1)`. + pub cursor_bytes: u64, } /// The retained sizes stored fader automation charges (#1646 D4): a plan with automation charges @@ -2579,6 +3401,9 @@ pub fn fader_automation_sizes() -> FaderAutomationSizes { program_overhead_bytes: bytes(core::mem::size_of::>()), offsets_cell_bytes: bytes(core::mem::size_of::>()), lane_slot_bytes: bytes(core::mem::size_of::>()), + fader_composite_bytes: bytes(core::mem::size_of::()), + mute_composite_bytes: bytes(core::mem::size_of::()), + cursor_bytes: bytes(automation::Composed::::bytes(1)), } } @@ -2637,46 +3462,109 @@ fn prepare_fader_automation( &'static str, )> = Vec::new(); let mut strips: BTreeMap, PreparedStripAutomation> = BTreeMap::new(); - for (strip, values) in model.strips().zip(effective.strips.iter()) { + // #1655: the VCA reach, read only when an entry drives a VCA row (a session without VCA + // automation reads its strips' own entries alone, as before). Indices into `model.vcas`, in + // ascending VCA ID. + let vca_rows = model.automation.iter().any(|entry| { + controls::vca_offset_row(&entry.target) || controls::vca_mute_row(&entry.target) + }); + let reach: Vec> = if vca_rows { + model.vca_reach() + } else { + Vec::new() + }; + let reach_of = |index: usize| reach.get(index).map_or(&[][..], Vec::as_slice); + // The fader and mute event lanes that automated VCA lanes reach, `(strip, lane, VCAs)`: their + // VCA cells follow every route cell, so they are filled in once those exist. Transient. + let mut composed_faders: Vec<(Box, usize, Vec)> = Vec::new(); + let mut composed_mutes: Vec<(Box, usize, Vec)> = Vec::new(); + // The reaching VCAs (indices) whose `entries` row drives lane `lane`. + let reaching = |index: usize, + lane: usize, + entries: for<'m> fn( + &'m session::SessionModel, + &session::StableId, + ) -> [Option<&'m session::Automation>; 2]| + -> Vec { + reach_of(index) + .iter() + .copied() + .filter(|&vca| { + model + .vcas + .get(vca) + .is_some_and(|vca| entries(model, &vca.id)[lane].is_some()) + }) + .collect() + }; + for (index, (strip, values)) in model.strips().zip(effective.strips.iter()).enumerate() { let entries = controls::fader_automation(model, strip.id); - if entries.iter().all(Option::is_none) { + let events = if vca_rows { + controls::fader_event_lanes(model, strip.id, reach_of(index)) + } else { + entries.map(|entry| entry.is_some()) + }; + if !events.contains(&true) { 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.fader", entry))? - .start_value; + if !events.get(lane).copied().unwrap_or(false) { + continue; + } + let own_db = [strip.fader.left_db, strip.fader.right_db][lane]; + let (cell, value) = match entry { + Some(entry) => { + 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.fader", entry))? + .start_value; + let cell = u32::try_from(tables.len()) + .map_err(|_| diag("host.automation.fader", "$.automation"))?; + tables.push(( + entry, + segments, + controls::FADER_CELL_RULE, + "host.automation.fader", + )); + (Some(cell), value) + } + None => (None, own_db), + }; let offsets_sum = values.offsets_sum[lane]; + let vcas = if vca_rows { + reaching(index, lane, controls::vca_offset_automation) + } else { + Vec::new() + }; // The caller's body holds `CanonicalFpEnv` across this composition and the lowering - // that bakes its gain (D2). + // that bakes its gain (D2). A lane reached by VCA curves composes once its VCA cells + // exist (below). let fader_db = session::vca_compose_db(value, offsets_sum); - // Defensive: the session validator bounds every `start_value` to the fader domain - // (#1645) and `vca_compose_db` clamps to it, so a validated session never fails this - // domain check. It refuses with a typed diagnostic rather than reach the lowering's - // `expect("preflighted parameters")`. - builtins::checked_fader_gain(fader_db) - .map_err(|_| refuse("host.automation.fader", entry))?; - let cell = u32::try_from(tables.len()) - .map_err(|_| diag("host.automation.fader", "$.automation"))?; + if vcas.is_empty() { + // Defensive: the session validator bounds every `start_value` to the fader + // domain (#1645) and `vca_compose_db` clamps to it, so a validated session never + // fails this domain check. It refuses with a typed diagnostic rather than reach + // the lowering's `expect("preflighted parameters")`. + if let Some(entry) = entry { + builtins::checked_fader_gain(fader_db) + .map_err(|_| refuse("host.automation.fader", entry))?; + } + } else { + composed_faders.push((Box::::from(strip.id.as_str()), lane, vcas)); + } cells[lane] = Some(PreparedFaderCell { cell, + vca: Box::default(), value, + own_db, fader_db, offsets_sum, offsets_ramp: ramp, offsets: Arc::new(OffsetsLane::new()), }); - tables.push(( - entry, - segments, - controls::FADER_CELL_RULE, - "host.automation.fader", - )); } strips.insert( Box::::from(strip.id.as_str()), @@ -2687,33 +3575,54 @@ fn prepare_fader_automation( ); } // #1651 D1: the mute cells, after every fader cell. - for (strip, values) in model.strips().zip(effective.strips.iter()) { + for (index, (strip, values)) in model.strips().zip(effective.strips.iter()).enumerate() { let entries = controls::mute_automation(model, strip.id); - if entries.iter().all(Option::is_none) { + let events = if vca_rows { + controls::mute_event_lanes(model, strip.id, reach_of(index)) + } else { + entries.map(|entry| entry.is_some()) + }; + if !events.contains(&true) { 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"))?; + if !events.get(lane).copied().unwrap_or(false) { + continue; + } + let (cell, value) = match entry { + Some(entry) => { + 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"))?; + tables.push(( + entry, + segments, + controls::MUTE_CELL_RULE, + "host.automation.mute", + )); + (Some(cell), value) + } + None => (None, 0.0), + }; + if vca_rows { + let vcas = reaching(index, lane, controls::vca_mute_automation); + if !vcas.is_empty() { + composed_mutes.push((Box::::from(strip.id.as_str()), lane, vcas)); + } + } cells[lane] = Some(PreparedMuteCell { cell, + vca: Box::default(), value, + curve_muted: curve_mutes(value), terms: values.mute[lane], }); - tables.push(( - entry, - segments, - controls::MUTE_CELL_RULE, - "host.automation.mute", - )); } strips .entry(Box::::from(strip.id.as_str())) @@ -2759,7 +3668,107 @@ fn prepare_fader_automation( }; tables.push((entry, segments, rule, "host.automation.route")); } - routes.insert(Box::::from(route.id.as_str()), cells); + routes.insert(Box::::from(route.id.as_str()), cells); + } + // #1655 D1: the VCA cells, after every route cell, in ascending VCA ID, then row (`offset_db` + // with the fader's rule, `mute` with the mute rule), then lane: one cell per automated VCA + // lane, shared by every member it reaches. A session without VCA automation builds the program + // it built before, cell for cell. + let mut vcas: BTreeMap, VcaCells> = BTreeMap::new(); + if vca_rows { + let mut order: Vec = (0..model.vcas.len()).collect(); + order.sort_by(|&one, &two| model.vcas[one].id.as_str().cmp(model.vcas[two].id.as_str())); + for index in order { + let vca = &model.vcas[index]; + let rows = [ + ( + controls::vca_offset_automation(model, &vca.id), + controls::FADER_CELL_RULE, + ), + ( + controls::vca_mute_automation(model, &vca.id), + controls::MUTE_CELL_RULE, + ), + ]; + if rows + .iter() + .all(|(entries, _)| entries.iter().all(Option::is_none)) + { + continue; + } + let mut cells = [[None; 2]; 2]; + for (row, (entries, rule)) in rows.into_iter().enumerate() { + 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. + if segments.is_empty() { + return Err(refuse("host.automation.vca", entry)); + } + cells[row][lane] = Some( + u32::try_from(tables.len()) + .map_err(|_| diag("host.automation.vca", "$.automation"))?, + ); + tables.push((entry, segments, rule, "host.automation.vca")); + } + } + vcas.insert(Box::::from(vca.id.as_str()), cells); + } + } + // Each composed lane takes its reaching VCA cells, in ascending VCA ID, and its prepared + // value: the own value with `S` and then each VCA curve's first `start_value` (D2), composed + // inside the caller's `CanonicalFpEnv`. + let vca_cell = |vca: usize, row: usize, lane: usize| { + let cells = vcas.get(model.vcas.get(vca)?.id.as_str())?; + cells.get(row)?.get(lane).copied().flatten() + }; + let first_value = |cell: u32| { + tables + .get(cell as usize) + .and_then(|(_, segments, _, _)| segments.first()) + .map(|first| first.start_value) + }; + for (track, lane, reaching) in composed_faders { + let Some(prepared) = strips + .get_mut(&track) + .and_then(|strip| strip.fader.get_mut(lane)) + .and_then(Option::as_mut) + else { + continue; + }; + let cells: Vec = reaching + .iter() + .filter_map(|&vca| vca_cell(vca, 0, lane)) + .collect(); + let seeds = cells + .iter() + .map(|&cell| first_value(cell)) + .collect::>>() + .ok_or_else(|| diag("host.automation.vca", "$.automation"))?; + prepared.fader_db = composed_db(prepared.value, prepared.offsets_sum, seeds.into_iter()); + // Defensive, as the lone cell's check: `vca_compose_db` clamps to the fader domain. + builtins::checked_fader_gain(prepared.fader_db) + .map_err(|_| diag("host.automation.vca", "$.automation"))?; + prepared.vca = cells.into_boxed_slice(); + } + for (track, lane, reaching) in composed_mutes { + let Some(prepared) = strips + .get_mut(&track) + .and_then(|strip| strip.mute.get_mut(lane)) + .and_then(Option::as_mut) + else { + continue; + }; + let cells: Vec = reaching + .iter() + .filter_map(|&vca| vca_cell(vca, 1, lane)) + .collect(); + for &cell in &cells { + let seed = + first_value(cell).ok_or_else(|| diag("host.automation.vca", "$.automation"))?; + prepared.curve_muted |= curve_mutes(seed); + } + prepared.vca = cells.into_boxed_slice(); } let descriptions: Vec> = tables .iter() @@ -2789,6 +3798,7 @@ fn prepare_fader_automation( program_bytes, strips, routes, + vcas, })) } @@ -5856,10 +6866,8 @@ impl PreparedBuiltinsSession { 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)); + let mute = &plan.strip(track_id)?.mute; + let cells = [0, 1].map(|lane| mute[lane].as_ref().and_then(|cell| cell.cell)); cells .iter() .any(Option::is_some) @@ -5881,6 +6889,19 @@ impl PreparedBuiltinsSession { Some((Arc::clone(&plan.program), cells)) } + /// VCA `vca_id`'s stored automation (#1655 D1), for the members' records and the following + /// routes of the host (V3, V4): the plan's one automation program (the `Arc` + /// [`Self::strip_mute_cells`] hands out) and the program's cell index of each `[row][lane]` + /// (row 0 `offset_db`, row 1 `mute`; lane 0 left, 1 right), `None` for a lane no entry drives. + /// `None` when the VCA automates neither row (or the session has no stored automation that + /// renders). Control side; it clones one `Arc`. + #[must_use] + pub fn vca_cells(&self, vca_id: &str) -> Option<(Arc, VcaCells)> { + let plan = self.fader_automation.as_ref()?; + let cells = *plan.vcas.get(vca_id)?; + Some((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 @@ -6059,7 +7080,7 @@ impl PreparedBuiltinsSession { // #1646 R17-4: the oracle installs the same lane automation as a fader bank lane. let automation = fader_automation.as_ref().and_then(|plan| { plan.strip(&strip.track_id) - .map(|cells| Box::new(LaneFaderAutomation::new(&plan.program, cells))) + .map(|cells| Box::new(bound_lane_automation(&plan.program, cells))) }); let StripPreparation { graph_id, @@ -6246,14 +7267,31 @@ impl PreparedBuiltinsSession { { continue; } + // #1655 R19-6: each composed channel's curve and cursor table (the reach pairs), as + // bank payload under the existing caps. + let (composed, _) = + automation.composed_bytes(members.iter().filter_map(|member| match member { + GraphNodeId::TrackStage { track_id, .. } => Some(track_id.as_str()), + _ => None, + }))?; payload_bytes = payload_bytes .checked_add(slots_per_bank)? - .checked_add(sizes.offsets_cell_bytes.checked_mul(channels)?)?; + .checked_add(sizes.offsets_cell_bytes.checked_mul(channels)?)? + .checked_add(composed)?; } if payload_bytes == 0 { return Some(none); } let program_bytes = automation.retained_program_bytes()?; + let (_, largest_composed) = automation.composed_bytes( + groups + .iter() + .flat_map(|members| members.iter()) + .filter_map(|member| match member { + GraphNodeId::TrackStage { track_id, .. } => Some(track_id.as_str()), + _ => None, + }), + )?; Some(GraphBuiltinBankResourceEstimate { payload_bytes: payload_bytes.checked_add(program_bytes)?, // A ceiling on the largest single allocation: the program's arrays are each within its @@ -6261,7 +7299,8 @@ impl PreparedBuiltinsSession { largest_allocation_bytes: slots_per_bank .max(sizes.offsets_cell_bytes) .max(sizes.program_overhead_bytes) - .max(automation.program_bytes), + .max(automation.program_bytes) + .max(largest_composed), ..none }) } @@ -6342,17 +7381,21 @@ impl PreparedBuiltinsSession { .count(), ) .ok()?; - let channels = plan.automated_channels(unbanked)?; + let channels = plan.automated_channels(unbanked.clone())?; + // #1655 R19-6: each composed channel's curve and cursor table. + let (composed, largest_composed) = plan.composed_bytes(unbanked)?; if strips == 0 { (0, 0) } else { ( lane.checked_mul(strips)? .checked_add(sizes.offsets_cell_bytes.checked_mul(channels)?)? + .checked_add(composed)? .checked_add(plan.retained_program_bytes()?)?, lane.max(sizes.offsets_cell_bytes) .max(sizes.program_overhead_bytes) - .max(plan.program_bytes), + .max(plan.program_bytes) + .max(largest_composed), ) } } @@ -6545,7 +7588,7 @@ impl PreparedBuiltinsSession { .map(|lane| { let track = members.get(lane).map(track_of)?; let cells = plan.strip(track)?; - Some(LaneFaderAutomation::new(&plan.program, cells)) + Some(bound_lane_automation(&plan.program, cells)) }) .collect() } @@ -7816,17 +8859,34 @@ fn resource_plan( // #1649 D4: the strips whose fader automation gives their producer offsets-cell writers (the // rule `prepare_fader_automation` and `FaderCells::attach_offsets` follow). Transient. let model = session.normalized_model(); + // #1655: a fader event lane an automated VCA `offset_db` lane reaches has an offsets cell + // too, also when its own fader is not automated. let automated: BTreeSet<&str> = if model.automation.is_empty() { BTreeSet::new() } else { + let vca_rows = model + .automation + .iter() + .any(|entry| controls::vca_offset_row(&entry.target)); + let reach = if vca_rows { + model.vca_reach() + } else { + Vec::new() + }; model .strips() - .filter(|strip| { - controls::fader_automation(model, strip.id) - .iter() - .any(Option::is_some) + .enumerate() + .filter(|(index, strip)| { + if vca_rows { + let reach = reach.get(*index).map_or(&[][..], Vec::as_slice); + controls::fader_event_lanes(model, strip.id, reach).contains(&true) + } else { + controls::fader_automation(model, strip.id) + .iter() + .any(Option::is_some) + } }) - .map(|strip| strip.id.as_str()) + .map(|(_, strip)| strip.id.as_str()) .collect() }; for control in controls { @@ -16251,7 +17311,7 @@ mod tests { .as_ref() .expect("left fader cell") .cell; - assert_eq!(fader_cell, 0, "the fader cell keeps its index"); + assert_eq!(fader_cell, Some(0), "the fader cell keeps its index"); let (program, cells) = both .strip_mute_cells(&track_name(0)) .expect("track 0 automates its mute"); @@ -18813,4 +19873,670 @@ mod tests { )]) ); } + + /// #1655 V2: one session's fader banks over its strips, stored automation bound as a fader + /// bank binds it, rendered from timeline 0 with every input sample `1.0` (so each output + /// sample is the lane's stage gain) and `a(n) = 0`. The jump lengths `[fader, mute, + /// vca_offset, vca_mute]` are set per block as `set_block_time` sets them. + /// Per block, per strip, its left and right gains as bits. + type RigBlocks = Vec; 2]>>; + + struct VcaRig { + processor: FaderBankProcessor, + plan: Option, + lanes: usize, + jumps: [u32; 4], + first: u64, + } + + fn vca_caps() -> CompileCaps { + 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, + } + } + + fn vca_rig(model: &session::SessionModel, backend: Backend, jumps: [u32; 4]) -> VcaRig { + let compiled = compile_session(model, vca_caps()).expect("the session compiles"); + let effective = effective_of(&compiled); + let _canonical = lane::CanonicalFpEnv::enter(); + let plan = prepare_fader_automation(&compiled, &effective).expect("prepared"); + let width = BankWidth::for_backend(backend).expect("a bank width"); + let model = compiled.normalized_model(); + let mut parameters = Vec::new(); + let mut automation = Vec::new(); + for (strip, values) in model.strips().zip(effective.strips.iter()) { + let id = strip.id.as_str(); + let prepared = plan + .as_ref() + .map_or((values.fader_db, values.mute), |plan| { + ( + plan.fader_db(id, values.fader_db), + plan.mute(id, values.mute), + ) + }); + parameters.push( + strip_parameters(&strip, prepared, caps().maximum_smoothing_samples) + .expect("parameters"), + ); + automation.push(plan.as_ref().and_then(|plan| { + plan.strip(id).map(|cells| { + LaneFaderAutomation::new(&plan.program, cells).expect("the lane binds") + }) + })); + } + let lanes = parameters.len(); + assert!( + lanes <= width.lanes() as usize, + "one bank holds the session" + ); + automation.resize_with(width.lanes() as usize, || None); + let automated = automation.iter().any(Option::is_some); + VcaRig { + processor: FaderBankProcessor { + bank: BuiltinFaderBank::new(backend, width, parameters).expect("bank"), + controls: (0..width.lanes()).map(|_| None).collect(), + automation: if automated { + automation.into_boxed_slice() + } else { + Box::default() + }, + process_calls: 0, + frames_processed: 0, + control_delivery: BuiltinControlDelivery::BetweenRenderCalls, + }, + plan, + lanes, + jumps, + first: 0, + } + } + + impl VcaRig { + /// One block of `frames`: per strip, its left and right gains, as bits. + fn block(&mut self, frames: u32) -> Vec<[Vec; 2]> { + let [fader, mute, vca_offset, vca_mute] = self.jumps; + for slot in self.processor.automation.iter_mut().flatten() { + slot.span = Some(TimelineSpan { + start: i64::try_from(self.first).expect("time"), + step: None, + }); + slot.fader_jump = Some(fader); + slot.mute_jump = Some(mute); + slot.vca_jumps = Some([vca_offset, vca_mute]); + } + let width = self.processor.bank.width().lanes() as usize; + let samples = width * frames as usize; + let (mut left, mut right) = (vec![1.0_f32; samples], vec![1.0_f32; samples]); + { + let _canonical = lane::CanonicalFpEnv::enter(); + self.processor + .process(&mut left, &mut right, frames, self.first) + .expect("process"); + } + self.first += u64::from(frames); + (0..self.lanes) + .map(|lane| { + [&left, &right].map(|plane| { + (0..frames as usize) + .map(|sample| plane[sample * width + lane].to_bits()) + .collect() + }) + }) + .collect() + } + + /// `blocks` blocks of `frames`, and every stored event the driver gave the stage. + fn run(&mut self, blocks: u32, frames: u32) -> (RigBlocks, Vec) { + let _ = test_only_take_fader_events(); + let mut out = Vec::new(); + let mut events = Vec::new(); + for _ in 0..blocks { + out.push(self.block(frames)); + let (taken, overflow) = test_only_take_fader_events(); + assert!(!overflow, "the event probe holds one block"); + events.extend(taken); + } + (out, events) + } + } + + fn vca_model(tracks: usize) -> session::SessionModel { + let mut model = n_track_session(tracks).normalized_model().clone(); + model.vcas.clear(); + model.automation.clear(); + model + } + + fn stable(id: &str) -> session::StableId { + session::StableId::parse(id).expect("stable ID") + } + + fn vca_of(id: &str, offset_db: f32, members: &[&str]) -> session::Vca { + session::Vca { + id: stable(id), + fader: session::DualMonoFader { + left_db: offset_db, + right_db: offset_db, + left_mute: false, + right_mute: false, + }, + members: members.iter().map(|member| stable(member)).collect(), + } + } + + fn vca_segment( + shape: session::AutomationShape, + span: (u64, u64), + values: (f32, f32), + unit: session::ParameterUnit, + ) -> session::AutomationSegment { + session::AutomationSegment { + shape, + start_sample: span.0, + end_sample: span.1, + start_value: values.0, + end_value: values.1, + unit, + } + } + + /// A `dB` or `0`/`1` step table: `before` until `at`, then `after` to 100,000. + fn step_table( + at: u64, + before: f32, + after: f32, + unit: session::ParameterUnit, + ) -> Vec { + vec![ + vca_segment( + session::AutomationShape::Step, + (0, at), + (before, before), + unit, + ), + vca_segment( + session::AutomationShape::Step, + (at, 100_000), + (after, after), + unit, + ), + ] + } + + /// A linear `dB` ride from `from` to `to` over `[0, end)`. + fn ride_table(end: u64, from: f32, to: f32) -> Vec { + vec![vca_segment( + session::AutomationShape::Linear, + (0, end), + (from, to), + session::ParameterUnit::Db, + )] + } + + fn stored( + name: &str, + owner: session::Owner, + control: u32, + lane: session::ParameterChannel, + segments: Vec, + ) -> session::Automation { + let section = match owner { + session::Owner::Vca(_) => session::Section::Own, + _ => session::Section::Builtins, + }; + session::Automation { + id: stable(name), + target: session::ControlAddress { + owner, + section, + control, + lane, + }, + segments, + offset: session::AutomationOffset::default(), + } + } + + fn vca_owner(id: &str) -> session::Owner { + session::Owner::Vca(stable(id)) + } + + fn strip_owner(index: usize) -> session::Owner { + session::Owner::Strip(stable(&track_name(index))) + } + + /// #1655 gate 1 at the fader stage (V2): a flat VCA `offset_db` ride at `-6` dB and a flat VCA + /// mute at `0` render, bit for bit, the session with those static values, for a member with + /// its own fader ride (`t01` left) and for lanes with none (`t00`, `t01` right). The ridden + /// VCA's static offset (`+3`, its fallback) is in no sum. + /// + /// Red if a composed channel adds the VCA curve with other arithmetic than the static path + /// (another order, an `f32` sum, the fallback in `S`), if its prepared gain differs, or if a + /// flat curve emits a move. + #[test] + fn a_flat_vca_ride_renders_the_static_vca_offset_bits() { + for backend in input_test_backends() { + let own = stored( + "own-ride", + strip_owner(1), + 5, + session::ParameterChannel::Left, + ride_table(4_096, -6.5, -2.0), + ); + let mut fixed = vca_model(2); + fixed.vcas = vec![vca_of("v", -6.0, &["t00", "t01"])]; + fixed.automation = vec![own.clone()]; + let mut ridden = fixed.clone(); + ridden.vcas = vec![vca_of("v", 3.0, &["t00", "t01"])]; + ridden.automation = vec![ + own, + stored( + "vca-ride", + vca_owner("v"), + 1, + session::ParameterChannel::Both, + ride_table(100_000, -6.0, -6.0), + ), + stored( + "vca-mute", + vca_owner("v"), + 2, + session::ParameterChannel::Both, + step_table(50_000, 0.0, 0.0, session::ParameterUnit::Linear), + ), + ]; + let mut fixed = vca_rig(&fixed, backend, [480; 4]); + let mut ridden = vca_rig(&ridden, backend, [480; 4]); + let plan = ridden.plan.as_ref().expect("cells"); + let t00 = plan.strip(&track_name(0)).expect("t00 is an event lane"); + assert!( + t00.fader + .iter() + .flatten() + .all(|cell| cell.cell.is_none() && cell.vca.len() == 1) + ); + assert!( + t00.mute + .iter() + .flatten() + .all(|cell| cell.cell.is_none() && cell.vca.len() == 1) + ); + let (fixed, _) = fixed.run(40, 128); + let (ridden, events) = ridden.run(40, 128); + assert_eq!(fixed, ridden, "{backend:?}"); + assert!( + events + .iter() + .all(|event| event.lane == 1 && event.channel == 0 && !event.mute), + "only the member's own ride moves: {backend:?}" + ); + } + } + + /// #1655 D2 (V2): every grid event of a member lane a linear VCA ride reaches is + /// `vca_compose_db(own, S + c)` at the grid ramp's completion, with `own` the member's own + /// ride (`t01` left) or its static fader dB (`t00`, `t01` right), and `S` its offset sum. + /// + /// Red if a composed channel reads the VCA curve at another time than its own, composes with + /// another own value, or drops a grid event. + #[test] + fn a_vca_ride_composes_with_each_members_own_value_at_every_grid_event() { + for backend in input_test_backends() { + let own_table = ride_table(4_096, -6.5, -2.0); + let vca_table = ride_table(8_192, -12.0, 0.0); + let mut model = vca_model(2); + model.vcas = vec![vca_of("v", 0.0, &["t00", "t01"])]; + model.automation = vec![ + stored( + "own-ride", + strip_owner(1), + 5, + session::ParameterChannel::Left, + own_table.clone(), + ), + stored( + "vca-ride", + vca_owner("v"), + 1, + session::ParameterChannel::Both, + vca_table.clone(), + ), + ]; + let stored_segments = |table: &[session::AutomationSegment]| { + table + .iter() + .map(|segment| automation::Segment { + start: segment.start_sample, + end: segment.end_sample, + start_value: segment.start_value, + end_value: segment.end_value, + shape: automation::Shape::Linear, + }) + .collect::>() + }; + let (own_curve, vca_curve) = (stored_segments(&own_table), stored_segments(&vca_table)); + let mut rig = vca_rig(&model, backend, [480; 4]); + let static_db = |track: usize, channel: usize| { + let strip = &model.tracks[track].fader; + [strip.left_db, strip.right_db][channel] + }; + let (_, events) = rig.run(24, 128); + let mut checked = [[0_usize; 2]; 2]; + for event in events.iter().filter(|event| !event.mute && !event.held) { + assert_eq!(event.kind, automation::EventKind::Grid, "{event:?}"); + let at = i64::try_from(event.sample).expect("time") + 63; + if at >= 4_096 { + continue; + } + let own = if (event.lane, event.channel) == (1, 0) { + automation::value_at(&own_curve, at) + } else { + static_db(event.lane, event.channel) + }; + let expected = composed_db( + own, + 0.0, + core::iter::once(automation::value_at(&vca_curve, at)), + ); + assert_eq!( + event.value.to_bits(), + expected.to_bits(), + "{event:?} {backend:?}" + ); + checked[event.lane][event.channel] += 1; + } + assert!( + checked.iter().flatten().all(|&count| count >= 40), + "every member lane moves on the grid: {checked:?}" + ); + } + } + + /// The non-held fader events of lane 0's left channel from sample 1,000 on, as `(sample, + /// kind, ramp)`. + fn moves_after_step(events: &[TestOnlyFaderEvent]) -> Vec<(u64, automation::EventKind, u32)> { + events + .iter() + .filter(|event| { + (event.lane, event.channel) == (0, 0) + && !event.mute + && !event.held + && event.sample >= 1_000 + }) + .map(|event| (event.sample, event.kind, event.ramp)) + .collect() + } + + /// #1655 R19-2 (V2): a VCA step inside a member's own grid ride keeps its jump length `J`, + /// and a member's own step inside a VCA ride keeps its `J`: the step at 1,000 jumps over + /// 480, the riding curve's grid points whose ramps would end inside it are held, and the + /// first one whose ramp ends after it (1,472) moves the lane again. + /// + /// Red if a grid event of one curve retargets the composed lane inside another curve's jump + /// (the step would end after 24 samples, at the grid point 1,024). + #[test] + fn a_step_inside_another_curves_ride_keeps_its_jump_length() { + for backend in input_test_backends() { + for vca_steps in [true, false] { + let (own, vca) = if vca_steps { + ( + ride_table(8_000, 0.0, -24.0), + step_table(1_000, -6.0, -12.0, session::ParameterUnit::Db), + ) + } else { + ( + step_table(1_000, -6.0, -12.0, session::ParameterUnit::Db), + ride_table(8_000, 0.0, -24.0), + ) + }; + let mut model = vca_model(2); + model.vcas = vec![vca_of("v", 0.0, &["t00"])]; + model.automation = vec![ + stored( + "own", + strip_owner(0), + 5, + session::ParameterChannel::Left, + own, + ), + stored( + "vca", + vca_owner("v"), + 1, + session::ParameterChannel::Left, + vca, + ), + ]; + let mut rig = vca_rig(&model, backend, [480; 4]); + let (_, events) = rig.run(16, 128); + assert_eq!( + moves_after_step(&events).get(..2), + Some( + &[ + (1_000, automation::EventKind::Jump, 480), + (1_472, automation::EventKind::Grid, 64), + ][..] + ), + "vca steps {vca_steps}, {backend:?}" + ); + } + } + } + + /// #1655 R19-4 (V2): a member's own step and a VCA step at one sample are one jump over the + /// longer of their rows' lengths, both ways round; an own unmute and a VCA unmute at one + /// sample are one `SetMute` over the longer of the mute and VCA mute lengths. + /// + /// Red if coincident events emit twice, or the composed jump takes the shorter or the first + /// curve's length. + #[test] + fn coincident_member_and_vca_events_take_the_longest_jump() { + for backend in input_test_backends() { + for (own_jump, vca_jump) in [(480, 960), (960, 480)] { + let db = session::ParameterUnit::Db; + let toggle = session::ParameterUnit::Linear; + let mut model = vca_model(2); + model.vcas = vec![vca_of("v", 0.0, &["t00"])]; + model.automation = vec![ + stored( + "own", + strip_owner(0), + 5, + session::ParameterChannel::Left, + step_table(1_000, -6.0, -12.0, db), + ), + stored( + "own-mute", + strip_owner(0), + 6, + session::ParameterChannel::Left, + step_table(1_000, 1.0, 0.0, toggle), + ), + stored( + "vca", + vca_owner("v"), + 1, + session::ParameterChannel::Left, + step_table(1_000, -3.0, -1.0, db), + ), + stored( + "vca-mute", + vca_owner("v"), + 2, + session::ParameterChannel::Left, + step_table(1_000, 1.0, 0.0, toggle), + ), + ]; + let mut rig = vca_rig(&model, backend, [own_jump, own_jump, vca_jump, vca_jump]); + let (_, events) = rig.run(12, 128); + let at_step = events + .iter() + .filter(|event| (event.lane, event.channel, event.sample) == (0, 0, 1_000)) + .map(|event| (event.mute, event.kind, event.ramp, event.held)) + .collect::>(); + let longest = own_jump.max(vca_jump); + assert_eq!( + at_step, + [ + (false, automation::EventKind::Jump, longest, false), + (true, automation::EventKind::Jump, longest, false), + ], + "{own_jump}/{vca_jump} {backend:?}" + ); + } + } + } + + /// #1655 D2 (V2): a session without VCA automation (static VCAs over automated members) and + /// one whose only VCA automation is a mute keep every fader channel a lone cell over its own + /// table, as before #1655, and build no VCA cell for the offset; so no fader event of such a + /// session goes through the composed walk and no bit of it can move. + /// + /// Red if a VCA without automation, or a VCA mute curve, turns a member's fader channel into a + /// composed one. + #[test] + fn a_session_without_vca_offset_automation_keeps_its_lone_fader_cells() { + let db = session::ParameterUnit::Db; + let toggle = session::ParameterUnit::Linear; + let mut model = vca_model(2); + model.vcas = vec![vca_of("v", -6.0, &["t00", "t01"])]; + model.automation = vec![ + stored( + "own", + strip_owner(0), + 5, + session::ParameterChannel::Both, + ride_table(4_096, -6.5, -2.0), + ), + stored( + "own-mute", + strip_owner(1), + 6, + session::ParameterChannel::Left, + step_table(1_000, 1.0, 0.0, toggle), + ), + ]; + let rig = vca_rig(&model, Backend::Simd4, [480; 4]); + let plan = rig.plan.as_ref().expect("cells"); + assert!(plan.vcas.is_empty(), "no VCA cell"); + for slot in rig.processor.automation.iter().flatten() { + assert!( + slot.channels + .iter() + .flatten() + .all(|cell| matches!(cell.curve, FaderCurve::Cell { .. })) + ); + assert!( + slot.mute + .iter() + .flatten() + .all(|cell| matches!(cell.curve, MuteCurve::Cell { .. })) + ); + } + model.automation.push(stored( + "vca-mute", + vca_owner("v"), + 2, + session::ParameterChannel::Both, + step_table(1_000, 0.0, 1.0, toggle), + )); + let _ = db; + let rig = vca_rig(&model, Backend::Simd4, [480; 4]); + let plan = rig.plan.as_ref().expect("cells"); + assert_eq!( + plan.vcas.get("v").copied(), + Some([[None, None], [Some(3), Some(4)]]), + "the VCA mute cells follow every strip cell, the offset row has none" + ); + for slot in rig.processor.automation.iter().flatten() { + assert!( + slot.channels + .iter() + .flatten() + .all(|cell| matches!(cell.curve, FaderCurve::Cell { .. })) + ); + assert!( + slot.mute + .iter() + .flatten() + .all(|cell| matches!(cell.curve, MuteCurve::Composed(_))) + ); + } + } + + /// #1648 D2 and #1655 D6 on a composed channel (V2): a plan swap at sample 1,100 that carries + /// every lane renders, bit for bit, the uninterrupted bank, whether the join continues the + /// composed cells verbatim (`continued_cells` set) or repositions them (clear), over a VCA + /// ride that reaches a member with its own ride and one with none (the swap inside a grid + /// ramp), and continued across a VCA step's jump (1,000 over 480: the own ride's grid stays + /// held to its end). + /// + /// Red if a composed channel's carry loses its cursors' or its output's state (a continued + /// walk restarts), or if its reposition compares at another time than the ramp's end (an + /// unchanged curve would jump at the swap). + #[test] + fn a_carried_composed_lane_renders_the_uninterrupted_bits() { + for backend in input_test_backends() { + for (continued, vca_table) in [ + (0b1111_u8, ride_table(8_192, -12.0, 0.0)), + (0, ride_table(8_192, -12.0, 0.0)), + ( + 0b1111, + step_table(1_000, -6.0, -12.0, session::ParameterUnit::Db), + ), + ] { + let mut model = vca_model(2); + model.vcas = vec![vca_of("v", 0.0, &["t00", "t01"])]; + model.automation = vec![ + stored( + "own-ride", + strip_owner(1), + 5, + session::ParameterChannel::Left, + ride_table(4_096, -6.5, -2.0), + ), + stored( + "vca-ride", + vca_owner("v"), + 1, + session::ParameterChannel::Both, + vca_table, + ), + stored( + "vca-mute", + vca_owner("v"), + 2, + session::ParameterChannel::Both, + step_table(1_500, 0.0, 1.0, session::ParameterUnit::Linear), + ), + ]; + let mut uninterrupted = vca_rig(&model, backend, [480; 4]); + let mut predecessor = vca_rig(&model, backend, [480; 4]); + let (expected, _) = uninterrupted.run(24, 100); + let (before, _) = predecessor.run(11, 100); + assert_eq!(before[..], expected[..11]); + let mut successor = vca_rig(&model, backend, [480; 4]); + for lane in 0..successor.lanes { + assert!(successor.processor.adopt_lane( + lane, + &predecessor.processor, + lane, + continued + )); + } + successor.first = predecessor.first; + let (after, _) = successor.run(13, 100); + assert_eq!( + after[..], + expected[11..], + "continued {continued:#b} {backend:?}" + ); + } + } + } } diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index 18cfd0c00..506bfb5f5 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -803,14 +803,22 @@ fn same_segments(before: &Automation, after: &Automation) -> bool { /// table (#1648 D2): the entry that drives the lane in `current` and the one that drives it in /// `next` ([`fader_automation`]) have the same segments, `f32` values by bits. A successor's strip /// join reads it: such a lane's cell continues its carried event state verbatim, and every other -/// carried cell is repositioned in its own table at adoption. +/// carried cell is repositioned in its own table at adoption. #1655 D6: a fader event lane +/// continues when its own table and each reaching automated VCA `offset_db` table (in ascending +/// VCA ID) are the same set, bit for bit (a lane with no own entry in either model included). #[must_use] pub fn continued_fader_lanes( current: &SessionModel, next: &SessionModel, strip: &StableId, ) -> [bool; 2] { - continued_lanes(current, next, strip, fader_automation) + continued_lanes( + current, + next, + strip, + fader_automation, + vca_offset_automation, + ) } /// The lanes of strip `strip`'s mute (`[left, right]`) that both models automate with the same @@ -823,23 +831,64 @@ pub fn continued_mute_lanes( next: &SessionModel, strip: &StableId, ) -> [bool; 2] { - continued_lanes(current, next, strip, mute_automation) + continued_lanes(current, next, strip, mute_automation, vca_mute_automation) } -/// The lanes that `entries` gives an entry in both models with the same segments. +/// The curves of one event lane in one model: its own entry, and each reaching VCA's entry on the +/// matching VCA row (`vca_entries`) with the VCA's ID, in ascending VCA ID (#1655 D6). +type LaneCurves<'a> = (Option<&'a Automation>, Vec<(&'a str, &'a Automation)>); + +/// The lanes of strip `strip` whose curves both models render with the same tables (#1648 D2, +/// #1655 D6): the own entry (`entries`) is absent in both or has the same segments, and the +/// reaching VCAs with an entry on the lane (`vca_entries`, by the rule of [`fader_event_lanes`]) +/// are the same VCAs, in ascending VCA ID, each with the same segments, and the lane has a curve. +/// With no VCA row automated in either model this is the own-entry rule alone, as before #1655. fn continued_lanes<'a>( current: &'a SessionModel, next: &'a SessionModel, strip: &StableId, entries: fn(&'a SessionModel, &StableId) -> [Option<&'a Automation>; 2], + vca_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) = (entries(current, strip), entries(next, strip)); + let curves = |model: &'a SessionModel| -> [LaneCurves<'a>; 2] { + let own = entries(model, strip); + let mut vcas = [Vec::new(), Vec::new()]; + if model.automation.iter().any(|entry| vca_row(&entry.target)) { + for vca in model + .vca_reach_of(strip) + .into_iter() + .filter_map(|index| model.vcas.get(index)) + { + let driven = vca_entries(model, &vca.id); + for (lane, curves) in vcas.iter_mut().enumerate() { + if let Some(entry) = driven[lane] { + curves.push((vca.id.as_str(), entry)); + } + } + } + } + let [left, right] = vcas; + [(own[0], left), (own[1], right)] + }; + let (before, after) = (curves(current), curves(next)); [0, 1].map(|lane| { - matches!((before[lane], after[lane]), (Some(before), Some(after)) - if same_segments(before, after)) + let ((own_before, vcas_before), (own_after, vcas_after)) = (&before[lane], &after[lane]); + let own = match (own_before, own_after) { + (Some(before), Some(after)) => same_segments(before, after), + (None, None) => true, + _ => false, + }; + own && (own_before.is_some() || !vcas_before.is_empty()) + && vcas_before.len() == vcas_after.len() + && vcas_before + .iter() + .zip(vcas_after) + .all(|((id_before, before), (id_after, after))| { + id_before == id_after && same_segments(before, after) + }) }) } diff --git a/crates/controls/tests/automation.rs b/crates/controls/tests/automation.rs index 3c0f9bd4c..d59b947fa 100644 --- a/crates/controls/tests/automation.rs +++ b/crates/controls/tests/automation.rs @@ -1588,3 +1588,61 @@ fn the_offset_sum_puts_the_static_vca_offsets_first() { .for_each(|value| sum.add(value)); assert_eq!(strip.offsets_sum[0].to_bits(), sum.sum().unwrap().to_bits()); } + +/// #1655 D6 (V2): a member lane's carried curve continues verbatim only while its own table and +/// every reaching automated VCA table are the same set, bit for bit: a lane with no own entry +/// that a VCA ride reaches continues; a changed VCA table, a VCA ride added on the lane (a nested +/// VCA) and a member that leaves the VCA each reposition; the mute row takes the VCA mute tables. +/// +/// Red if the carry predicates read only the own entry (a VCA table edit would continue a stale +/// cursor, or an event lane with no own entry would never continue). +#[test] +fn a_member_lane_continues_only_while_its_reaching_vca_tables_do() { + use controls::{continued_fader_lanes, continued_mute_lanes}; + let current = ridden_vca(); + let vocal = id("vocal"); + let both = |next: &SessionModel| { + ( + continued_fader_lanes(¤t, next, &vocal), + continued_mute_lanes(¤t, next, &vocal), + ) + }; + assert_eq!(both(¤t.clone()), ([true, false], [true, true])); + let mut moved = current.clone(); + moved.automation[0].segments[0].end_value = -9.0; + assert_eq!(both(&moved), ([false, false], [true, true])); + let mut nested = current.clone(); + nested.vcas.push(vca("outer", [0.0, 0.0], &["riders"])); + nested.automation.push(one( + "outer-ride", + vca_own("outer", VCA_OFFSET_DB, Lane::Left), + ParameterUnit::Db, + -3.0, + )); + assert_eq!(both(&nested), ([false, false], [true, true])); + let mut left = current.clone(); + left.vcas[0].members.clear(); + assert_eq!(both(&left), ([false, false], [false, false])); + // An own fader entry on the lane: the own table and the VCA table must both continue. + let mut own = current.clone(); + own.automation.push(one( + "own", + strip_builtin("vocal", 5, Lane::Left), + ParameterUnit::Db, + -2.0, + )); + let mut own_moved = own.clone(); + own_moved.automation[2].segments[0].end_value = -4.0; + assert_eq!( + continued_fader_lanes(&own, &own.clone(), &vocal), + [true, false] + ); + assert_eq!( + continued_fader_lanes(&own, &own_moved, &vocal), + [false, false] + ); + assert_eq!( + continued_fader_lanes(¤t, &own, &vocal), + [false, false] + ); +} diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index e2f9cd675..a7ebd8e46 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -4651,6 +4651,7 @@ pub(crate) fn level_records_between( offsets[lane] = Some(FaderOffsetsRecord { offsets_sum: after.offsets_sum[lane], smoothing_samples: lengths.fader[lane], + own_db: None, }); } } diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index a393cd8e4..f14c993a0 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -1203,6 +1203,7 @@ fn render_parity(mut rig: Rig, blocks: usize, offsets: bool) -> [Vec; 2] { builtins_compiler::FaderOffsetsRecord { offsets_sum: -3.0, smoothing_samples: 200, + own_db: None, }, ); } @@ -1220,6 +1221,7 @@ fn render_parity(mut rig: Rig, blocks: usize, offsets: bool) -> [Vec; 2] { builtins_compiler::FaderOffsetsRecord { offsets_sum: -6.0, smoothing_samples: 480, + own_db: None, }, ); } @@ -1888,6 +1890,7 @@ fn live_offsets(rig: &mut Rig, offsets_sum: f64, window: u32) { builtins_compiler::FaderOffsetsRecord { offsets_sum, smoothing_samples: window, + own_db: None, }, ); } @@ -2214,6 +2217,7 @@ fn acted( builtins_compiler::FaderOffsetsRecord { offsets_sum, smoothing_samples, + own_db: None, }, ); } @@ -3095,6 +3099,7 @@ fn level_move_run_muted( builtins_compiler::FaderOffsetsRecord { offsets_sum, smoothing_samples, + own_db: None, }, ); } diff --git a/crates/host-core/tests/stored_mute_automation.rs b/crates/host-core/tests/stored_mute_automation.rs index c1b9946e8..3bbef57d1 100644 --- a/crates/host-core/tests/stored_mute_automation.rs +++ b/crates/host-core/tests/stored_mute_automation.rs @@ -294,6 +294,7 @@ impl Rig { builtins_compiler::FaderOffsetsRecord { offsets_sum, smoothing_samples: window, + own_db: None, }, ); } From b70b899d852780038d3a5579a18e5915eb9027c2 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 14:58:37 +0000 Subject: [PATCH 18/36] Prepare VCA event lanes and lower VCA edits (#1655 V3) Fader event lanes take no FaderDb: a hand move on a member only a VCA ride reaches writes own_db through the offsets cell; a hand move on an automated VCA offset rewrites S of every reached lane over the edit's ramp; a hand VCA mute is the terms write; a reach change of an automated VCA rebuilds. The join releases lanes that stop being event lanes; following routes use the mute event lanes and the prep exports the VCA mute cells for V4. Interim (until V4): a send following a lane only a VCA mute reaches is refused at attach (host.route_control.attach). Co-Authored-By: Claude Opus 5.5 --- ...r-stored-vca-offset-and-mute-automation.md | 63 ++ crates/host-core/src/live_delta.rs | 50 +- crates/host-core/src/prepare.rs | 226 ++++-- .../host-core/tests/stored_vca_automation.rs | 755 ++++++++++++++++++ 4 files changed, 1032 insertions(+), 62 deletions(-) create mode 100644 crates/host-core/tests/stored_vca_automation.rs diff --git a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md index c6eff4581..225a8e3f0 100644 --- a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md +++ b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md @@ -538,3 +538,66 @@ fader event lanes as `automated` in `level_records_between` / `fader_records` (n a composed lane: the drain still applies a `FaderDb` to the stage); `has_offsets` is true on every fader event lane. For V4: a source lane's VCA mute cells are its reach (ascending VCA ID) mapped through `vca_cells(..)[1][lane]`, jump row `VcaMute`. + +## Decision record: V3 (batch 19) + +host-core preparation and the classifier (`crates/host-core/src/{prepare.rs,live_delta.rs}`). No +render code, no edit under `crates/engine/src/realtime/`, no `control.rs` edit (#1711 landed in +19a, so `automates` stays), no row class or table change (no SDK regeneration). + +- **Records (D3).** `level_records_between` takes the strip's own faders before and after and a + `FaderEventLanes` (`controls::fader_event_lanes` as `events`, `automated_fader_lanes` as `own`). + `fader_records` reads `events` as its automated lanes, so no `FaderDb` reaches an event lane. An + event lane gets one offsets write when its `S` moves or, on a lane whose own fader is not + automated, when its static own dB moves; that write carries `own_db = Some(own after)` (else + `None`) and the lane's fader length (`LevelRules::lengths`: `resolve(Fader, edit)` for a hand + move). Dense diff, local (fast-path) diff and the strip join all call it with the reach they hold. +- **Clamp.** `level_differs` also compares the own static fader dB: at the effective clamp a hand + move leaves `fader_db` and `S` as they were but a composed lane hears it. On any other lane the + extra "moved" yields no record, so no non-VCA-automation answer changes. +- **VCA hand offset (D5).** `LevelRules::vca_changed` (offset) also compares the driving entry's + hand offset (`vca_offset_moved`: `vca_offset_automation(current)` against + `ModelView::vca_automation(next)`), so members' writes take the VCA edit's length, not the + session's. `S` itself is V0's (`effective_strip`). +- **VCA mute (D4, D5).** No new record: the effective mute (`terms`) change is the `Mute` record, + which V2's composed mute lane takes as `terms`. +- **Reach rule.** No classifier change: V0's `rendered_automation_changed` gives step 3 `Automation`; + step 3's doc now names the VCA rows and the reach rule. +- **Join (D6).** `join_level_records` releases lanes that stop being fader / mute event lanes + (`fader_event_lanes` / `mute_event_lanes` over `reach_before` and `reach`). The carry byte is V2's + widened `continued_*_lanes`, unchanged here. +- **Slot.** V2 already gives every fader event lane its `PreparedFaderCell` and offsets cell + (`attach_offsets` over `cells.fader`); V3 only gates it. +- **Route (D4).** `following_route_lanes` widens `lanes` to `mute_event_lanes` (reach read through + `vca_reach_of` only when an entry drives a VCA `mute` row and an own lane is not automated), so the + attach, #1653's records, the local route scan and `join_routes` read one predicate. The attach + checks `own cell || VCA cells` per lane against it, with the new + `follow_vca_mute_cells(vca_cells, model, source, program) -> Option<[Vec; 2]>`: per source + lane, `PreparedBuiltinsSession::vca_cells(vca)[1][lane]` of each reaching automated VCA mute, in + ascending VCA ID, `None` on a plan/model mismatch. + +**Departure / interim.** Until V4, a following send whose source lane only a VCA mute reaches has +`automated[lane] = true` and no own cell, so `GraphFollowRouteSource::new` refuses it and preparation +fails with `host.route_control.attach` (typed, no panic). V4 passes `follow_vca_mute_cells`' lists +to the source (graph) and relaxes `new`'s `flags.automated == cells` check to `own || VCA`. A lane +with its own mute cell and a VCA mute prepares, and until V4 its send follows only the own curve. + +**Gates** (`crates/host-core/tests/stored_vca_automation.rs`, renders on Simd8 under AVX2, Simd4 and +the scalar oracle, live and between render calls, tail bits equal to a fresh plan, the edit heard, +render allocations and frees 0): `every_fader_event_lane_has_an_offsets_cell` (test-support); +under `control-provider`, full and fast path equal: +`a_hand_move_on_a_member_only_a_vca_ride_reaches_writes_its_own_db`, +`a_hand_move_on_an_automated_vca_offset_rewrites_every_reached_lanes_s`, +`a_hand_mute_of_an_automated_vca_writes_each_reached_lanes_terms`, +`a_reach_change_of_an_automated_vca_rebuilds_and_of_a_plain_vca_is_live`, +`a_hand_move_hidden_by_the_static_clamp_still_writes_own_db`. + +Commands: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` and no-feature +`cargo check --locked --all-targets` on host-core, control-plane, builtins-compiler; tests host-core +`--features test-support` (477 passed) and `test-support,control-provider` (605), control-plane +`--features test-support`, builtins-compiler, capi lib (218): all green. Realtime policy: 172 +regions in 35 files (unchanged). + +For V4: read `follow_vca_mute_cells` in the attach loop (`prepare.rs`, the `follow_cells` push) and +pass its lists into `GraphFollowRouteSource`; jump row `VcaMute`. For V5: host parity, the cap +gate (R19-6), gates 1-3 and 7-8 on both hosts. diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index 8d9dbb399..4fa2f9209 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -54,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, SendRecord, StripChange, - StripEditRamps, effect_instance_records, follow_route_record_between, following_route_lanes, - input_records, level_differs, level_records_between, matrix_record, route_gain_offset, - route_record_between, route_source_strip, same_params, strip_at, + EffectiveDiff, FaderEventLanes, 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_gain_offset, route_record_between, route_source_strip, same_params, strip_at, }; use crate::prepare::{GraphJumpLengths, LiveRampRow, LiveRamps, MonitorBase, session_jump_lengths}; @@ -69,9 +69,11 @@ pub struct LiveStripRecords<'a> { /// For the strip's fader/mute queue, in push order: `FaderDb` records first, then `Mute` /// records, each left before right; `None` after the last one. pub fader: [Option; 4], - /// For the offsets cells of the strip's automated fader lanes (#1649 D4), `[left, right]`: - /// the lane's new `S` and the length of its jump, when `S` changes on a lane the post-commit - /// model automates. A host writes each to the lane's cell (`FaderCells::write_offsets`). + /// For the offsets cells of the strip's fader event lanes (#1649 D4, #1655 D3), `[left, + /// right]`: the lane's new `S` and the length of its jump, when `S` changes on a lane the + /// post-commit model renders by events, or, on such a lane whose own fader is not automated, + /// when its static fader moves (then `own_db` carries it, and no `FaderDb` is emitted). A host + /// writes each to the lane's cell (`FaderCells::write_offsets`). pub offsets: [Option; 2], /// For the strip's matrix/pan queue: the new lowered target, when it changes. pub matrix: Option, @@ -274,11 +276,17 @@ pub use crate::prepare::LiveRebuild; /// `next`'s automations (decision 15 E1, #1335 D4, #1566 D6). An unchanged automation table /// over unchanged activity and unchanged filter statics is never re-checked: it was prepared. /// 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 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 +/// ([`controls::rendered_automation_changed`], #1648 D1): an entry on such a row (a strip's +/// fader and mute, builtin rows 5 and 6, a route's rows 1-6 and a VCA's `offset_db` and `mute`, +/// rows 1 and 2, [`controls::automation_row_renders`], #1651, #1654, #1655 D6) is added, +/// removed, retargeted or given other segments, or the `(member strip, lane)` set an automated +/// VCA lane reaches differs (a membership or nesting edit of an automated VCA, #1655 D5: each +/// new pair needs new event state). 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: a hand move of an +/// automated fader or VCA offset is a live offsets-cell write of each lane it reaches, and a +/// hand fader move on a lane only a VCA curve reaches writes that lane's `own_db` (#1655 D3), +/// never a `FaderDb`. A reach change of VCAs no rendered entry drives stays live (#1649). 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 @@ -1280,11 +1288,21 @@ impl<'a> LocalDiff<'a> { } } + /// Whether `strip`'s level moved ([`level_differs`]: its effective values or its own fader). + fn differs(&self, strip: &LocalStrip) -> bool { + let change = self.change(strip); + level_differs( + &strip.before, + &strip.after, + [change.own_before, change.own_after], + ) + } + /// The strips whose effective fader or mute differs between the models, ascending. fn level_changed_strips(&self) -> Vec { self.strips .iter() - .filter(|strip| level_differs(&strip.before, &strip.after)) + .filter(|strip| self.differs(strip)) .map(|strip| strip.index) .collect() } @@ -1307,8 +1325,7 @@ impl<'a> LocalDiff<'a> { impl LevelDiff for LocalDiff<'_> { fn level_changed(&self, strip: usize) -> bool { - self.strip(strip) - .is_some_and(|strip| level_differs(&strip.before, &strip.after)) + self.strip(strip).is_some_and(|strip| self.differs(strip)) } fn level_records(&self, strip: usize) -> Result { @@ -1317,8 +1334,9 @@ impl LevelDiff for LocalDiff<'_> { level_records_between( &strip.before, &strip.after, + [change.own_before, change.own_after], self.rules.lengths(&change), - || controls::automated_fader_lanes(self.rules.next.structure(), change.id), + || FaderEventLanes::of(self.rules.next.structure(), change.id, change.reach), ) } diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 38db621cf..93dfb1353 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -2915,9 +2915,19 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( (None, Some((program, rows))) => (program, [None; 2], rows), _ => return Err(graph_failure("host.route_control.attach")), }; - if cells.map(|cell| cell.is_some()) != automated.lanes - || rows.map(|cell| cell.is_some()) != automated.rows - { + // #1655 D4: a source lane's curve is its own mute cell, then the reaching automated VCA + // mute cells in ascending VCA ID; `automated.lanes` names the lanes that have one. The + // route op's VCA mute cursors read `vca_mutes` (V4); until then a lane that only a VCA + // mute reaches has no own cell, and the graph source refuses its flags. + let vca_mutes = follow_vca_mute_cells( + |vca| builtins.vca_cells(vca), + model, + route_source_id(route), + &program, + ) + .ok_or_else(|| graph_failure("host.route_control.attach"))?; + let curves = [0, 1].map(|lane| cells[lane].is_some() || !vca_mutes[lane].is_empty()); + if curves != automated.lanes || rows.map(|cell| cell.is_some()) != automated.rows { return Err(graph_failure("host.route_control.attach")); } let flags = follow_route_flags(route, effective.strips[source].mute, automated); @@ -4406,6 +4416,49 @@ impl SendRecord { /// The flags word of a following route (#1652 D1, #1654 D2): the route's `mute` and /// `follows_mute`, the source lanes' `terms` and the plan's automated set (bits 4-11). +/// The VCA mute curves of strip `source`'s lanes that a following route's op ORs into its follow +/// term at `a(tap)` (#1655 D4, R19-2): per source lane `[left, right]`, the program cell index of +/// each reaching VCA's automated `mute` lane (`PreparedBuiltinsSession::vca_cells`, row 1, the +/// lane's own index), in ascending VCA ID (the order of [`SessionModel::vca_reach_of`]). +/// `vca_cells` is the plan's `PreparedBuiltinsSession::vca_cells`. Empty lanes when no entry drives +/// a VCA `mute` row. `None` when a reaching VCA's entry has no cell in `program`, the plan's one +/// automation program: a plan that disagrees with its model. Control side; it allocates the two +/// lists. +pub(crate) fn follow_vca_mute_cells

( + vca_cells: impl Fn(&str) -> Option<(std::sync::Arc

, builtins_compiler::VcaCells)>, + model: &SessionModel, + source: &session::StableId, + program: &std::sync::Arc

, +) -> Option<[Vec; 2]> { + let mut lanes = [Vec::new(), Vec::new()]; + if !model + .automation + .iter() + .any(|entry| controls::vca_mute_row(&entry.target)) + { + return Some(lanes); + } + for index in model.vca_reach_of(source) { + let vca = &model.vcas.get(index)?.id; + let driven = controls::vca_mute_automation(model, vca); + if driven.iter().all(Option::is_none) { + continue; + } + let (cells_program, cells) = vca_cells(vca.as_str())?; + if !std::sync::Arc::ptr_eq(&cells_program, program) { + return None; + } + for (lane, list) in lanes.iter_mut().enumerate() { + match (driven[lane], cells[1][lane]) { + (Some(_), Some(cell)) => list.push(cell), + (None, None) => {} + _ => return None, + } + } + } + Some(lanes) +} + pub(crate) fn follow_route_flags( route: &session::Route, terms: [bool; 2], @@ -4554,9 +4607,10 @@ pub(crate) struct LevelLengths { /// the same value and the same length). A fader move on a lane that stays muted still gets its /// record, so the stage remembers the gain for a later unmute. /// -/// A lane that `automated` names (the lanes the post-change model automates, -/// [`controls::automated_fader_lanes`]) gets no `FaderDb` (#1648 D3): its cell renders the curve -/// and the static value is only its fallback. That is no error, so a successor's join reads the +/// A lane that `automated` names (the fader event lanes of the post-change model, +/// [`FaderEventLanes::events`]) gets no `FaderDb` (#1648 D3, #1655 D3): its cell renders the curve +/// (its own, or a reaching VCA's composed with the member's `own_db`, which the lane's offsets +/// record carries) and the static value is only its fallback. That is no error, so a successor's join reads the /// strip as unchanged when nothing else moved, and its stage carries. Its `Mute` records are /// unchanged. `automated` is called only when a lane's gain bits change. /// @@ -4626,13 +4680,45 @@ pub(crate) struct LevelRecords { pub(crate) offsets: [Option; 2], } +/// The fader lanes of one strip that a plan of the post-change model renders by events (#1655 D2, +/// D3): `events`, the fader event lanes ([`controls::fader_event_lanes`]: an own fader entry, or +/// an automated VCA `offset_db` lane that reaches the lane), and `own`, the lanes of `events` whose +/// own fader is automated ([`controls::automated_fader_lanes`]). An event lane outside `own` hears +/// the member's static fader dB (`own_db`) through its offsets cell. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct FaderEventLanes { + pub(crate) events: [bool; 2], + pub(crate) own: [bool; 2], +} + +impl FaderEventLanes { + /// The event lanes of strip `strip` in `model`, `reach` its VCA reach there (indices into + /// `model.vcas`, [`SessionModel::vca_reach`]). + pub(crate) fn of(model: &SessionModel, strip: &session::StableId, reach: &[usize]) -> Self { + Self { + events: controls::fader_event_lanes(model, strip, reach), + own: controls::automated_fader_lanes(model, strip), + } + } + + /// Whether `lane` is an event lane whose own fader is not automated: its offsets cell holds + /// `own_db` (#1655 D3). + const fn static_own(self, lane: usize) -> bool { + self.events[lane] && !self.own[lane] + } +} + /// The level records that bring a strip from `before`'s effective values to `after`'s: its -/// [`fader_records`], and for each lane that `automated` names (the lanes the post-change model -/// automates) whose `S` ([`controls::EffectiveStrip::offsets_sum`]) differs in bits, one -/// offsets-cell write of `after`'s `S` with the lane's fader length (#1649 D4, departure 1: the -/// one length rule, so an automated and a plain member of one VCA edit ramp alike). A VCA ride, a -/// membership change, a nested VCA, an added and a removed VCA each change only `S` on such a -/// lane. `automated` is asked at most once. +/// [`fader_records`] with the fader event lanes (`lanes().events`) as the automated lanes, so no +/// `FaderDb` reaches a lane that renders by events (#1648 D3, #1655 D3), and one offsets-cell write +/// per event lane whose `S` ([`controls::EffectiveStrip::offsets_sum`]) differs in bits, or, on an +/// event lane whose own fader is not automated, whose static own fader dB differs in bits (`own`, +/// the strip's own faders before and after). The write carries `after`'s `S` and the lane's fader +/// length (#1649 D4, departure 1: the one length rule, so an automated and a plain member of one +/// VCA edit ramp alike; a hand fader move takes the `Fader` row's length, `LevelRules::lengths`), +/// and on such a lane `own_db = Some(after's own fader dB)` (#1655 D3), else `None`. A VCA ride, a +/// membership change, a nested VCA, an added and a removed VCA each change only `S` on an event +/// lane. `lanes` is asked at most once. /// /// # Errors /// @@ -4640,28 +4726,34 @@ pub(crate) struct LevelRecords { pub(crate) fn level_records_between( before: &controls::EffectiveStrip, after: &controls::EffectiveStrip, + own: [&session::DualMonoFader; 2], lengths: LevelLengths, - automated: impl Fn() -> [bool; 2], + lanes: impl Fn() -> FaderEventLanes, ) -> Result { - let lanes = core::cell::OnceCell::new(); - let automated = || *lanes.get_or_init(&automated); + let asked = core::cell::OnceCell::new(); + let lanes = || *asked.get_or_init(&lanes); let fader = fader_records( &effective_fader(before), &effective_fader(after), lengths, - automated, + || lanes().events, )?; let mut offsets = [None; 2]; - let moved = + let own_db = + |fader: &session::DualMonoFader, lane: usize| [fader.left_db, fader.right_db][lane]; + let sum_moved = [0, 1].map(|lane| before.offsets_sum[lane].to_bits() != after.offsets_sum[lane].to_bits()); - if moved.contains(&true) { - let automated = automated(); + let own_moved = + [0, 1].map(|lane| own_db(own[0], lane).to_bits() != own_db(own[1], lane).to_bits()); + if sum_moved.contains(&true) || own_moved.contains(&true) { + let lanes = lanes(); for lane in 0..2 { - if moved[lane] && automated[lane] { + let static_own = lanes.static_own(lane); + if lanes.events[lane] && (sum_moved[lane] || (static_own && own_moved[lane])) { offsets[lane] = Some(FaderOffsetsRecord { offsets_sum: after.offsets_sum[lane], smoothing_samples: lengths.fader[lane], - own_db: None, + own_db: static_own.then(|| own_db(own[1], lane)), }); } } @@ -4669,18 +4761,24 @@ pub(crate) fn level_records_between( Ok(LevelRecords { fader, offsets }) } -/// Whether a strip's effective fader, mute or offset sum `S` differs, as bits: the one test of -/// "this strip's level moved", for the dense diff and the local one. `S` moves alone when a VCA -/// edit leaves the effective fader's bits (a clamp, a zero's sign), and an automated lane hears -/// `S` (#1649). +/// Whether a strip's effective fader, mute or offset sum `S`, or its own static fader dB (`own`, +/// before and after), differs, as bits: the one test of "this strip's level moved", for the dense +/// diff and the local one. `S` moves alone when a VCA edit leaves the effective fader's bits (a +/// clamp, a zero's sign), and an automated lane hears `S` (#1649). The own fader moves alone at a +/// clamp, and a fader event lane whose own fader is not automated hears it (`own_db`, #1655 D3) +/// composed with the VCA curves, which can leave the clamp; on any other lane such a move gives +/// no record ([`level_records_between`]). #[cfg(feature = "control-provider")] pub(crate) fn level_differs( before: &controls::EffectiveStrip, after: &controls::EffectiveStrip, + own: [&session::DualMonoFader; 2], ) -> bool { + let db = |fader: &session::DualMonoFader| [fader.left_db, fader.right_db].map(f32::to_bits); before.fader_db.map(f32::to_bits) != after.fader_db.map(f32::to_bits) || before.mute != after.mute || before.offsets_sum.map(f64::to_bits) != after.offsets_sum.map(f64::to_bits) + || db(own[0]) != db(own[1]) } /// A strip's effective fader and mute as a [`session::DualMonoFader`], the shape its stage's @@ -4842,9 +4940,24 @@ impl LevelRules<'_> { } else { [prior.fader.left_db, prior.fader.right_db][lane].to_bits() != [vca.fader.left_db, vca.fader.right_db][lane].to_bits() + || self.vca_offset_moved(&vca.id, lane) } } + /// Whether the hand offset of the entry that drives VCA `vca`'s `offset_db` on `lane` differs + /// between the models (#1655 D5): a hand move of an automated VCA offset writes it, so it is + /// the VCA's own offset edit there, and its members' records take the VCA's edit length. + fn vca_offset_moved(&self, vca: &session::StableId, lane: usize) -> bool { + if self.current.automation.is_empty() { + return false; + } + let offset = |entries: [Option<&session::Automation>; 2]| { + entries[lane].map(|entry| [entry.offset.left_db, entry.offset.right_db][lane].to_bits()) + }; + offset(controls::vca_offset_automation(self.current, vca)) + != offset(self.next.vca_automation(vca)) + } + /// The ramp length of a route record whose gate changed (#1558 D7): the route's own `mute` or /// `follows_mute` edit when one of them changed (rows `SendMute`, `SendFollowsMute`, with /// `own_edit` the route's `RouteMute` length), else the length that its source strip's `Mute` @@ -4951,9 +5064,15 @@ impl<'a> EffectiveDiff<'a> { } #[cfg(feature = "control-provider")] - /// Whether the strip's effective fader or mute differs, as bits, between the two models. + /// Whether the strip's effective fader or mute (or its own static fader, [`level_differs`]) + /// differs, as bits, between the two models. pub(crate) fn level_changed(&self, before: usize, after: usize) -> bool { - level_differs(&self.before.strips[before], &self.after.strips[after]) + let change = self.change(before, after); + level_differs( + &self.before.strips[before], + &self.after.strips[after], + [change.own_before, change.own_after], + ) } /// The level records of one strip, its effective values in the two models with the lengths @@ -4971,8 +5090,9 @@ impl<'a> EffectiveDiff<'a> { level_records_between( &self.before.strips[before], &self.after.strips[after], + [change.own_before, change.own_after], self.rules.lengths(&change), - || controls::automated_fader_lanes(self.rules.next.structure(), change.id), + || FaderEventLanes::of(self.rules.next.structure(), change.id, change.reach), ) } @@ -5010,16 +5130,16 @@ impl<'a> EffectiveDiff<'a> { let next = self.rules.next.structure(); let lost = |lanes: [bool; 2], kept: [bool; 2]| [0, 1].map(|lane| lanes[lane] && !kept[lane]); + // #1655 D6: an event lane (an own curve, or a reaching automated VCA's) that the change + // makes a plain lane is released; a lane that stays an event lane keeps its composed + // stage, which the carry repositions. let fader_released = lost( - controls::automated_fader_lanes(self.rules.current, change.id), - controls::automated_fader_lanes(next, change.id), + controls::fader_event_lanes(self.rules.current, change.id, change.reach_before), + controls::fader_event_lanes(next, change.id, change.reach), ); - 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), + controls::mute_event_lanes(self.rules.current, change.id, change.reach_before), + controls::mute_event_lanes(next, change.id, change.reach), ); if fader_released == [false; 2] && mute_released == [false; 2] { return Ok(level); @@ -5140,20 +5260,23 @@ fn route_source_id(route: &session::Route) -> &session::StableId { } /// Whether a plan of `model` gives `route` the seven-word cell of a following route (#1652 D1, -/// #1654 D2): it goes into a submix and either its source strip automates the mute of a lane -/// ([`controls::mute_automation`], the entries the builtins plan compiles mute cells from) or the -/// route automates one of its own rows 1-6 ([`controls::route_automation`], the entries the -/// builtins plan compiles route cells from). Every other route keeps the six-word cell. A route -/// into the output never follows. +/// #1654 D2, #1655 D4): it goes into a submix and either a lane of its source strip is a mute +/// event lane ([`controls::mute_event_lanes`]: its own mute is automated, the entries the builtins +/// plan compiles mute cells from, or an automated VCA `mute` lane reaches it) or the route +/// automates one of its own rows 1-6 ([`controls::route_automation`], the entries the builtins +/// plan compiles route 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 { following_route_lanes(model, route).is_some() } -/// What a plan of `model` automates of `route` (flags bits 4-11: the source lanes whose mute has -/// a cell and the route rows 1-6 that have one), when that plan gives `route` the seven-word cell -/// of a following route ([`follows_automated_mute`]), else `None`: the automated set of the -/// route's records (#1652 D1, #1653 D1, #1654 D2). +/// What a plan of `model` automates of `route` (flags bits 4-11: the source lanes that have a mute +/// curve, own or a reaching automated VCA's, and the route rows 1-6 that have a cell), when that +/// plan gives `route` the seven-word cell of a following route ([`follows_automated_mute`]), else +/// `None`: the automated set of the route's records (#1652 D1, #1653 D1, #1654 D2, #1655 D4). The +/// source's VCA reach is read only when an entry drives a VCA `mute` row and an own mute lane is +/// not automated. #[must_use] pub(crate) fn following_route_lanes( model: &SessionModel, @@ -5166,9 +5289,20 @@ pub(crate) fn following_route_lanes( { return None; } + let source = route_source_id(route); + let own = controls::mute_automation(model, source).map(|entry| entry.is_some()); + let lanes = if own == [true; 2] + || !model + .automation + .iter() + .any(|entry| controls::vca_mute_row(&entry.target)) + { + own + } else { + controls::mute_event_lanes(model, source, &model.vca_reach_of(source)) + }; let set = graph::RouteAutomatedSet { - lanes: controls::mute_automation(model, route_source_id(route)) - .map(|entry| entry.is_some()), + lanes, rows: controls::route_automation(model, &route.id).map(|entry| entry.is_some()), }; (set.lanes.contains(&true) || set.rows.contains(&true)).then_some(set) diff --git a/crates/host-core/tests/stored_vca_automation.rs b/crates/host-core/tests/stored_vca_automation.rs new file mode 100644 index 000000000..c91e1dff8 --- /dev/null +++ b/crates/host-core/tests/stored_vca_automation.rs @@ -0,0 +1,755 @@ +//! Stored VCA automation in host-core preparation and the classifier (#1655 V3): every fader event +//! lane (an own fader curve, or an automated VCA `offset_db` lane that reaches it) has an offsets +//! cell; a hand fader move on an event lane with no own fader automation writes that lane's +//! `own_db` through the cell (D3), never a `FaderDb`; a hand move of an automated VCA `offset_db` +//! writes the entry's offset and rewrites `S` of every lane the VCA reaches (D5); a hand mute of an +//! automated VCA writes the reached lanes' `terms` (a `Mute` record, D4); a reach change of an +//! automated VCA is a rebuild, of a VCA no entry drives a live write (D5, root's ruling). +//! +//! Every render here prepares on a chosen backend (Simd8 on an AVX2 target, Simd4, the scalar +//! oracle), a `test-support` entry of host-core; the classifier is `control-provider`. +#![cfg(feature = "test-support")] + +use engine::realtime::{PlanarBufferMut, RenderIo, RenderTime}; +use host_core::{HostPrepareCaps, HostShapePolicy, PreparedHost, SourceSubmission}; +use session::{ + Automation, AutomationSegment, AutomationShape, ControlAddress, ControlSmoothing, + DualMonoFader, Owner, ParameterChannel, ParameterUnit, Section, SessionModel, StableId, Vca, + 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 strip fader row (builtin parameter 5). +const FADER_DB: u32 = 5; +/// The VCA rows: `offset_db` (1) and `mute` (2). +const VCA_OFFSET: u32 = 1; +const VCA_MUTE: u32 = 2; + +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, + } +} + +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()))) +} + +fn normalized(model: &SessionModel) -> SessionModel { + compiled(model).normalized_model().clone() +} + +/// The first `tracks` tracks of the nine-track fixture at quantum `quantum`, as a normalized model: +/// no console, no insert, no input filter, an identity matrix, each routed to the output, faders +/// at 0 dB unmuted, `control_smoothing` fader 5 ms (240 samples), mute 10 ms (480). 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.fader = DualMonoFader { + left_db: 0.0, + right_db: 0.0, + left_mute: false, + right_mute: false, + }; + 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, + }); + normalized(&model) +} + +/// VCA `name` at 0 dB, unmuted, holding `members`. +fn vca(name: &str, members: Vec) -> Vca { + Vca { + id: id(name), + fader: DualMonoFader { + left_db: 0.0, + right_db: 0.0, + left_mute: false, + right_mute: false, + }, + members, + } +} + +/// A flat entry `entry` on `owner`'s row `control` (section `section`), on `lane`, at `value` in +/// `unit` (a step segment over the first 4096 samples, then held). +fn flat( + entry: &str, + owner: Owner, + section: Section, + control: u32, + lane: ParameterChannel, + value: f32, + unit: ParameterUnit, +) -> Automation { + Automation { + id: id(entry), + target: ControlAddress { + owner, + section, + control, + lane, + }, + segments: vec![AutomationSegment { + shape: AutomationShape::Step, + start_sample: 0, + end_sample: 4096, + start_value: value, + end_value: value, + unit, + }], + offset: session::AutomationOffset::default(), + } +} + +/// A flat VCA `offset_db` ride of VCA `name` on `lane` at `db`. +fn vca_ride(name: &str, lane: ParameterChannel, db: f32) -> Automation { + flat( + &format!("{name}-ride"), + Owner::Vca(id(name)), + Section::Own, + VCA_OFFSET, + lane, + db, + ParameterUnit::Db, + ) +} + +/// A flat VCA `mute` entry of VCA `name` on both lanes, unmuted. +fn vca_mute_entry(name: &str) -> Automation { + flat( + &format!("{name}-mute"), + Owner::Vca(id(name)), + Section::Own, + VCA_MUTE, + ParameterChannel::Both, + 0.0, + ParameterUnit::Linear, + ) +} + +/// A flat own fader ride of track `track` on both lanes at `db`. +fn own_ride(model: &SessionModel, track: usize, db: f32) -> Automation { + flat( + &format!("fader-{track}"), + Owner::Strip(model.tracks[track].id.clone()), + Section::Builtins, + FADER_DB, + ParameterChannel::Both, + db, + ParameterUnit::Db, + ) +} + +/// Every backend of the target. +fn backends() -> Vec { + vec![ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + graph_compiler::Backend::Scalar, + ] +} + +/// A plan of `model` with live controls on `backend` (`between`: delivery between render calls), +/// rendered block by block on a DC source of `1.0`. +struct Rig { + prepared: PreparedHost, + handles: host_core::HostLiveControlHandles, + gate: engine::realtime::PlanRevisionGate, + quantum: usize, + rendered: u64, + out: [Vec; 2], + /// The render calls' allocations and frees. + allocations: (u64, u64), +} + +impl Rig { + fn new(model: &SessionModel, backend: graph_compiler::Backend, between: bool) -> Self { + let compiled = compiled(model); + let caps = caps(model); + let request = host_core::HostLiveControlRequest::default(); + let (mut prepared, handles) = if between { + host_core::prepare::test_only_prepare_host_runtime_between_render_calls_on( + &compiled, &caps, &request, backend, + ) + } else { + host_core::test_only_prepare_host_runtime_with_live_controls_on( + &compiled, &caps, &request, backend, + ) + } + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))); + let gate = prepared.plan.take_revision_gate().expect("the plan's gate"); + Self { + prepared, + handles, + gate, + quantum: model.quantum_frames as usize, + rendered: 0, + out: [Vec::new(), Vec::new()], + allocations: (0, 0), + } + } + + /// The position of strip `strip` among the handles' producers. + fn strip(&self, strip: &str) -> usize { + self.handles + .strips + .iter() + .position(|held| held.as_ref() == strip) + .expect("a strip of the plan") + } + + /// Whether strip `strip`'s fader channels have an offsets cell, `[left, right]`. + fn has_offsets(&self, strip: &str) -> [bool; 2] { + let fader = &self.handles.strip_controls[self.strip(strip)].fader; + [fader.has_offsets(0), fader.has_offsets(1)] + } + + /// Render one block. + fn block(&mut self) { + let quantum = self.quantum; + let plane = vec![1.0_f32; quantum]; + self.prepared + .sources + .submit( + NINE_SOURCE, + SourceSubmission { + generation: 1, + start_frame: self.rendered, + sample_rate_hz: RATE, + planes: &[&plane, &plane], + 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_support::alloc::current_thread_counters(); + self.prepared + .plan + .render( + RenderIo { output }, + RenderTime { + absolute_sample: self.rendered, + }, + ) + .expect("render"); + let delta = bench_support::alloc::current_thread_delta_since(mark); + self.allocations.0 += delta.allocations + delta.reallocations; + self.allocations.1 += delta.deallocations; + self.out[0].extend_from_slice(&samples[..quantum]); + self.out[1].extend_from_slice(&samples[quantum..]); + self.rendered += quantum as u64; + } + + fn blocks(&mut self, count: usize) { + for _ in 0..count { + self.block(); + } + } +} + +/// #1655 D3 and the batch 19 amendment's Slot: every fader event lane has an offsets cell, also +/// when its own fader is not automated, and only event lanes do. Track 0 and track 1 are members +/// of `vca`, whose left `offset_db` lane rides; track 1 also rides its own fader on both lanes; +/// track 2 is in no VCA. +/// +/// Test value: red if an event lane that only a VCA ride reaches has no offsets cell (its `own_db` +/// and `S` writes would be refused at the commit's pre-write check), or if a lane outside the +/// ride (the right lane of track 0, track 2) gets one. +#[test] +fn every_fader_event_lane_has_an_offsets_cell() { + let mut model = plain(3, 128); + let (t0, t1, t2) = ( + model.tracks[0].id.clone(), + model.tracks[1].id.clone(), + model.tracks[2].id.clone(), + ); + model.vcas = vec![vca("vca", vec![t0.clone(), t1.clone()])]; + model + .automation + .push(vca_ride("vca", ParameterChannel::Left, -6.0)); + let ride = own_ride(&model, 1, -3.0); + model.automation.push(ride); + let model = normalized(&model); + for backend in backends() { + for between in [false, true] { + let rig = Rig::new(&model, backend, between); + let what = format!("{backend:?} between {between}"); + assert_eq!(rig.has_offsets(t0.as_str()), [true, false], "{what}"); + assert_eq!(rig.has_offsets(t1.as_str()), [true, true], "{what}"); + assert_eq!(rig.has_offsets(t2.as_str()), [false, false], "{what}"); + } + } +} + +#[cfg(feature = "control-provider")] +mod classifier { + use super::*; + use host_core::{ + BuiltinLaneSelector, FaderOffsetsRecord, LiveDelta, LiveRamps, LiveRebuild, + TrackFaderRecord, classify_live_delta, + }; + use session::{EditRampRow, EditRampTarget, EditRamps}; + + /// An edit of a VCA's reach, given the tracks' IDs. + type ReachEdit = fn(&mut SessionModel, &[StableId]); + + fn edited(current: &SessionModel, edit: impl FnOnce(&mut SessionModel)) -> SessionModel { + let mut next = current.clone(); + next.revision += 1; + edit(&mut next); + next + } + + /// One strip's records, owned: its ID, its fader/mute records and its offsets writes. + type Level = ( + String, + Vec, + [Option; 2], + ); + + fn level(delta: &LiveDelta<'_>) -> Vec { + delta + .strips + .iter() + .map(|strip| { + ( + strip.strip_id.to_owned(), + strip.fader_records().collect(), + strip.offsets, + ) + }) + .collect() + } + + fn classify( + current: &SessionModel, + next: &SessionModel, + edit_ramps: &EditRamps, + ) -> Result, LiveRebuild> { + classify_live_delta(current, next, LiveRamps::for_session(next), edit_ramps) + .map(|delta| level(&delta)) + } + + /// [`classify`] of a transaction of one `SetControl` on `row`, which the fast path + /// (`classify_live_edit` with the row) judges with the same records. + fn classify_set( + current: &SessionModel, + next: &SessionModel, + edit_ramps: &EditRamps, + row: &ControlAddress, + ) -> Result, LiveRebuild> { + let full = classify(current, next, edit_ramps); + let _ = host_core::take_fast_classification_count(); + let fast = host_core::classify_live_edit( + current, + next, + LiveRamps::for_session(next), + edit_ramps, + host_core::MonitorBase::NONE, + Some(core::slice::from_ref(row)), + ) + .map(|delta| level(&delta)); + assert_eq!( + host_core::take_fast_classification_count(), + 1, + "the fast path" + ); + assert_eq!(fast, full, "the fast path's records"); + full + } + + fn offsets( + offsets_sum: f64, + smoothing_samples: u32, + own_db: Option, + ) -> FaderOffsetsRecord { + FaderOffsetsRecord { + offsets_sum, + smoothing_samples, + own_db, + } + } + + /// `S` of strip `strip` (strips order) on `lane` as `controls::effective` composes it. + fn s_of(model: &SessionModel, strip: usize, lane: usize) -> f64 { + controls::effective(model, &controls::MonitorOverlay::default()).strips[strip].offsets_sum + [lane] + } + + fn address(owner: Owner, section: Section, control: u32) -> ControlAddress { + ControlAddress { + owner, + section, + control, + lane: ParameterChannel::Both, + } + } + + /// Two tracks in `vca`, whose `offset_db` rides at -6 dB on both lanes and whose `mute` has + /// an entry; track 1 also rides its own fader at -3 dB. + fn session() -> SessionModel { + let mut model = plain(2, 128); + let members = model.tracks.iter().map(|track| track.id.clone()).collect(); + model.vcas = vec![vca("vca", members)]; + model + .automation + .push(vca_ride("vca", ParameterChannel::Both, -6.0)); + model.automation.push(vca_mute_entry("vca")); + let ride = own_ride(&model, 1, -3.0); + model.automation.push(ride); + normalized(&model) + } + + /// Write `delta`'s strip records through `rig`'s live cells, as the shared commit does. + fn write(rig: &mut Rig, delta: &[Level]) { + use builtins_compiler::TestOnlyWriteGate; + let positions: Vec = delta.iter().map(|(strip, ..)| rig.strip(strip)).collect(); + let handles = &mut rig.handles; + rig.gate.write_published(|stamp| { + for ((_, fader, cells), &position) in delta.iter().zip(&positions) { + let producer = &mut handles.strip_controls[position]; + for &record in fader { + producer.fader.write(stamp, record); + } + for (channel, record) in cells.iter().enumerate() { + if let Some(record) = *record { + assert!(producer.fader.has_offsets(channel), "a cell to write"); + producer.fader.write_offsets(stamp, channel, record); + } + } + } + }); + } + + /// Render `current` on `backend` for 8 blocks, write the classifier's records of + /// `current -> next` and render 24 more; a fresh plan of `next` renders 32 blocks. The last 8 + /// blocks of each (every ramp long done) are equal, bit for bit, they differ from the block + /// before the write (the edit is heard), and the render thread allocated and freed nothing. + fn assert_heard_as_fresh(current: &SessionModel, next: &SessionModel, records: &[Level]) { + for backend in backends() { + for between in [false, true] { + let what = format!("{backend:?} between {between}"); + let mut live = Rig::new(current, backend, between); + live.blocks(8); + write(&mut live, records); + live.blocks(24); + let mut fresh = Rig::new(next, backend, between); + fresh.blocks(32); + let tail = 24 * live.quantum..32 * live.quantum; + for lane in 0..2 { + let (heard, expected) = ( + &live.out[lane][tail.clone()], + &fresh.out[lane][tail.clone()], + ); + assert!( + heard + .iter() + .zip(expected) + .all(|(a, b)| a.to_bits() == b.to_bits()), + "{what}: lane {lane} heard {:?}, a fresh plan {:?}", + heard[0], + expected[0] + ); + } + assert_ne!( + live.out[0][8 * live.quantum - 1].to_bits(), + live.out[0][tail.start].to_bits(), + "{what}: the edit is heard" + ); + assert_eq!(live.allocations, (0, 0), "{what}: the render thread"); + } + } + } + + /// Gate 4 (a hand member move, D3): a hand fader move on track 0, which only the VCA ride + /// reaches, is live with one offsets write per lane carrying `own_db` and the unchanged `S`, + /// with the `Fader` row's length (the edit's own 256 samples), and no `FaderDb`; track 1 + /// (whose own fader rides) is untouched. After the jump the plan sounds as a fresh plan of the + /// edited session, on every backend. + /// + /// Test value: red if the move lands as a `FaderDb` (which the composed lane's stage would + /// apply over the VCA curve), if the record leaves `own_db` out (the move is acked and never + /// heard), or if it takes the VCA row's length instead of the fader's. + #[test] + fn a_hand_move_on_a_member_only_a_vca_ride_reaches_writes_its_own_db() { + let current = session(); + let t0 = current.tracks[0].id.clone(); + let mut ramp = EditRamps::new(); + ramp.set( + &EditRampTarget::Strip(t0.clone()), + EditRampRow::Fader, + Some(256), + ); + let row = address(Owner::Strip(t0.clone()), Section::Builtins, FADER_DB); + let next = edited(¤t, |next| { + controls::set(next, &row, -10.0, Some(256), RATE).expect("a hand move"); + }); + let s = s_of(&next, 0, 0); + assert_eq!( + s.to_bits(), + s_of(¤t, 0, 0).to_bits(), + "S does not move" + ); + let records = classify_set(¤t, &next, &ramp, &row).expect("live"); + assert_eq!( + records, + vec![( + t0.as_str().to_owned(), + Vec::new(), + [Some(offsets(s, 256, Some(-10.0))); 2] + )] + ); + // Without an edit length, the session's fader length. + let session_length = classify(¤t, &next, &EditRamps::new()).expect("live"); + assert_eq!(session_length[0].2, [Some(offsets(s, 240, Some(-10.0))); 2]); + assert_heard_as_fresh(¤t, &next, &records); + } + + /// Gate 4 (a hand VCA move, D5): `SET_CONTROL offset_db = +3` on the automated VCA writes the + /// ride's offset and is live: each member lane gets one offsets write of the new `S` (the + /// VCA's hand offset) with the VCA row's edit length; track 0's carries its static `own_db`, + /// track 1's (own ride) none; no `FaderDb`. After the jump it sounds as a fresh plan. + /// + /// Test value: red if the hand move does not reach a member's `S` (acked, never heard), if a + /// member takes a `FaderDb`, or if the records take the session length instead of the VCA + /// edit's (the hand offset is the VCA's own edit). + #[test] + fn a_hand_move_on_an_automated_vca_offset_rewrites_every_reached_lanes_s() { + let current = session(); + let (t0, t1) = (current.tracks[0].id.clone(), current.tracks[1].id.clone()); + let mut ramp = EditRamps::new(); + ramp.set( + &EditRampTarget::Vca(id("vca")), + EditRampRow::VcaFader, + Some(128), + ); + let row = address(Owner::Vca(id("vca")), Section::Own, VCA_OFFSET); + let next = edited(¤t, |next| { + controls::set(next, &row, 3.0, Some(128), RATE).expect("a hand move"); + }); + assert_eq!(next.vcas, current.vcas, "the static offset is the fallback"); + assert_eq!( + next.automation + .iter() + .find(|entry| entry.id == id("vca-ride")) + .expect("the ride") + .offset + .left_db + .to_bits(), + 3.0_f32.to_bits() + ); + let s = s_of(&next, 0, 0); + assert_eq!(s.to_bits(), f64::from(3.0_f32).to_bits()); + let records = classify_set(¤t, &next, &ramp, &row).expect("live"); + assert_eq!( + records, + vec![ + ( + t0.as_str().to_owned(), + Vec::new(), + [Some(offsets(s, 128, Some(0.0))); 2] + ), + ( + t1.as_str().to_owned(), + Vec::new(), + [Some(offsets(s_of(&next, 1, 0), 128, None)); 2] + ), + ] + ); + assert_heard_as_fresh(¤t, &next, &records); + } + + /// Gate 4 (a hand VCA mute, D5): a hand mute of the automated VCA is live and writes each + /// reached lane's `terms` (one `Mute` record per member, the session mute length); the curve + /// is open, so the members close, as a fresh plan with the static mute does. + /// + /// Test value: red if a hand mute of an automated VCA is refused or reaches no member, or if + /// the composed mute does not OR it with the open curve. + #[test] + fn a_hand_mute_of_an_automated_vca_writes_each_reached_lanes_terms() { + let current = session(); + let row = address(Owner::Vca(id("vca")), Section::Own, VCA_MUTE); + let next = edited(¤t, |next| { + controls::set(next, &row, 1.0, None, RATE).expect("a hand mute"); + }); + let records = classify_set(¤t, &next, &EditRamps::new(), &row).expect("live"); + let muted = TrackFaderRecord::Mute { + lanes: BuiltinLaneSelector::Both, + muted: true, + smoothing_samples: 480, + }; + assert_eq!( + records, + current + .tracks + .iter() + .map(|track| (track.id.as_str().to_owned(), vec![muted], [None; 2])) + .collect::>() + ); + assert_heard_as_fresh(¤t, &next, &records); + } + + /// D5's last bullet (root's ruling): a membership edit of the automated VCA (a member removed, + /// a member added, a nesting edit) is a rebuild (`Automation`); the same edits of a VCA no + /// entry drives stay live, as #1649 made them. + /// + /// Test value: red if an automated VCA's reach change commits live, with no event state for a + /// new pair (or a stale one for a lost pair). + #[test] + fn a_reach_change_of_an_automated_vca_rebuilds_and_of_a_plain_vca_is_live() { + let mut base = plain(3, 128); + let t: Vec = base.tracks.iter().map(|track| track.id.clone()).collect(); + base.vcas = vec![vca("vca", vec![t[0].clone()]), vca("vcb", Vec::new())]; + let plain_vca = normalized(&base); + base.automation + .push(vca_ride("vca", ParameterChannel::Both, -6.0)); + let automated = normalized(&base); + let edits: [(&str, ReachEdit); 3] = [ + ("a member added", |next, t| { + next.vcas[0].members.push(t[1].clone()) + }), + ("a member removed", |next, _| next.vcas[0].members.clear()), + ("a nesting edit", |next, t| { + next.vcas[1].members.push(t[2].clone()); + next.vcas[0].members.push(id("vcb")); + }), + ]; + for (what, edit) in edits { + let next = edited(&automated, |next| edit(next, &t)); + assert_eq!( + classify(&automated, &next, &EditRamps::new()), + Err(LiveRebuild::Automation), + "automated VCA: {what}" + ); + let next = edited(&plain_vca, |next| edit(next, &t)); + assert!( + classify(&plain_vca, &next, &EditRamps::new()).is_ok(), + "plain VCA: {what}" + ); + } + // An edit of a VCA no entry drives, beside the automated one, stays live: a static move + // of `vcb` reaching track 2 alone is a `FaderDb` there. + let mut beside = automated.clone(); + beside.vcas[1].members.push(t[2].clone()); + let beside = normalized(&beside); + let next = edited(&beside, |next| next.vcas[1].fader.left_db = -6.0); + assert_eq!( + classify(&beside, &next, &EditRamps::new()), + Ok(vec![( + t[2].as_str().to_owned(), + vec![TrackFaderRecord::FaderDb { + lanes: BuiltinLaneSelector::Left, + db: -6.0, + smoothing_samples: 240, + }], + [None; 2] + )]) + ); + } + + /// D3 at the clamp: track 0 sits at +20 dB under a static VCA at +10 dB (the effective fader + /// clamps to +24) and an automated VCA ride at -6 dB. A hand move to +16 dB leaves the static + /// effective fader (`16 + 10` clamps to +24 too) and `S` as they were, but the composed lane + /// hears it (`16 + 10 - 6 = 20` dB, not 24), so the move still writes `own_db`. + /// + /// Test value: red if the classifier decides "level moved" from the clamped effective fader + /// alone, so an acked hand move on a composed lane is never written. + #[test] + fn a_hand_move_hidden_by_the_static_clamp_still_writes_own_db() { + let mut model = plain(1, 128); + let t0 = model.tracks[0].id.clone(); + model.tracks[0].fader.left_db = 20.0; + model.tracks[0].fader.right_db = 20.0; + let mut boost = vca("vca-a", vec![t0.clone()]); + boost.fader.left_db = 10.0; + boost.fader.right_db = 10.0; + model.vcas = vec![boost, vca("vca-b", vec![t0.clone()])]; + model + .automation + .push(vca_ride("vca-b", ParameterChannel::Both, -6.0)); + let current = normalized(&model); + let row = address(Owner::Strip(t0.clone()), Section::Builtins, FADER_DB); + let next = edited(¤t, |next| { + controls::set(next, &row, 16.0, None, RATE).expect("a hand move"); + }); + let effective = |model: &SessionModel| { + controls::effective(model, &controls::MonitorOverlay::default()).strips[0] + }; + assert_eq!( + effective(¤t).fader_db, + effective(&next).fader_db, + "clamped" + ); + let s = s_of(&next, 0, 0); + let records = classify_set(¤t, &next, &EditRamps::new(), &row).expect("live"); + assert_eq!( + records, + vec![( + t0.as_str().to_owned(), + Vec::new(), + [Some(offsets(s, 240, Some(16.0))); 2] + )] + ); + assert_heard_as_fresh(¤t, &next, &records); + } +} From 9381bf0cdb546ce5f7dc8e17354a91f03ce6e0c3 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 15:11:04 +0000 Subject: [PATCH 19/36] Fix four route-column re-time defects found in review (K1 + #1654 U2 fold 1) Held value events are heard, so a gate event inside a hold composes the grid's current values; a live move in flight at an equal-table swap keeps its length; removing a route row's or the source mute's automation lands the column on its static value over the removed row's length (after a move in flight, at its free sample); a value step keeps its jump length under another curve's ride (with_held_grid, ported from #1655 V2, on for every route column). Cursor tables are allocated at attach with a typed refusal. Gates for each, red before. Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 67 ++ crates/automation/src/compose.rs | 89 ++- crates/automation/src/compose/tests.rs | 61 ++ crates/capi/src/runtime/live_tests.rs | 65 ++ crates/graph/src/lib.rs | 34 +- crates/graph/src/runtime.rs | 701 +++++++++++++++++- crates/host-core/src/prepare.rs | 3 +- crates/host-core/tests/route_mute.rs | 63 ++ hosts/host-web/tests/control_parity.rs | 56 ++ 9 files changed, 1085 insertions(+), 54 deletions(-) diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 05a3b36e6..b940a48d8 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -739,3 +739,70 @@ swap there (outside this issue; not investigated). `docs/SESSION_SCHEMA_V1.md`: the send gain takes the shared offset, routes into the output stay prepared. Realtime policy: 171 marked regions in 35 files (unchanged). `scripts/check-graph-policy.sh`: PASS. + +## Decision record: U2 fold 1 (batch 19) + +Review fold of the U2 FAIL (four MAJOR, one MINOR, one NIT). Files: `crates/automation/src/compose.rs` +(+ `compose/tests.rs`), `crates/graph/src/{runtime.rs,lib.rs}`, `crates/host-core/src/prepare.rs` +(one match arm, mechanical), and the gates below. Nothing under `crates/engine/src/realtime/`; no +row class or table changed (no SDK regeneration). + +- **MAJOR-1 (R19-3: held events are heard).** K1's `merge` now sets a cursor's heard value from + every event, held or not; a held event still does not retarget the output. A gate event or a + gate-only record inside a hold composes the grid's value. K = 1 is unchanged (all K1 gates green). +- **MAJOR-2 (R19-5, equal bits at a swap).** `place_carried`'s equal branch now defers the placed + value cursors to the column ramp's first free sample when a ramp is in flight (as the moved-value + branch does), so a repositioned table's later moves wait for the live move's end. +- **MAJOR-3 (a removed row or source mute curve).** `adopt` records per column the roles the + predecessor's column heard and the successor's does not (`FollowCursors::dropped`). At the first + block a column that composes otherwise with no placed value, or that lost a role, lands over the + removed row's own length (`SendMatrix`/`SendGain`, the mute length for a mute curve; the longer + one with a coincident moved value, R19-4): at once when settled (a column jump), else at the + column ramp's free sample through an owed landing (`RouteColumn::owe_landing`: the column's first + value cursor, else its first gate, is placed with no target and defers its jump to the free + sample, so its jump there always emits; `apply_owed` gives that jump the owed length per block + until it fires). A column with no curve left uses the removed row's length instead of the mute + length. +- **MAJOR-4 (value jumps keep their length).** K1's `with_held_grid` (+ `clear_target`, + `output_state`, `set_output`) and its test ported from #1655 V2 (`4d700eee7`), text identical; + `RouteColumn::bind` turns it on for every route column. It holds the other value cursors' grid + through `hold_until` (never a deferral), so no landing alternates. **Departure:** this replaces + K1's "value jumps are not column jumps" departure for route columns; the ported doc line of + `with_held_grid` ("Without it (a route column, R19-3) ...") is kept verbatim for a clean merge + with #1655 and is stale for the route. +- **MINOR (not fixed).** A gate change on a column whose entries are both `+0` still commits on the + silent bit and defers the value cursors for `J` with no column ramp running. The deferral is made + inside K1's walk, which cannot see the route's column ramps; the small fixes either add a K1 hook + (a merge conflict with #1655's identical text) or drop the silent bit from change only (which + moves the route's activity flag). Phase 2. +- **NIT.** The cursor tables are now allocated at attach (`FollowColumns::bind` in + `follow_route_control_pair`), so a failed allocation is the typed refusal + `GraphRouteControlError::CursorTable(ComposeError)` (host-core maps it to + `host.route_control.attach`); bind moves the tables into the op. No `expect` remains. +- **Wasm.** `ColumnRamps::retarget_column` indexes through `get`/`get_mut`; the new render helpers + (`apply_owed`, `owe_landing`, the `place_carried` branches) use no index. + +Gates (red before the fix, green after): +- MAJOR-1: graph `a_reopen_inside_a_gate_jump_composes_the_held_grid_values` (red: the reopen aimed + at the curve at 1,023, `ll` 0.8424562). +- MAJOR-2: graph `a_hand_move_in_flight_at_an_equal_swap_keeps_its_length` (red: block 31, the + 2,000-sample move replaced by a 64-sample grid ramp). +- MAJOR-3: graph `a_removed_send_row_lands_the_column_on_its_static_value` (red: `ll` stays 0.4375; + mute case: column stays `[0, 0]`) and `a_removed_send_row_lands_after_a_move_in_flight` (red: no + landing after the move); host-core `route_mute.rs` + `removing_a_send_row_or_its_source_mute_curve_lands_on_the_fresh_plan` (Simd8 when avx2, Simd4, + scalar; red: rr sample 0 0.10067598 != 0.20135196, mute 0.0 != -0.22991964); capi + `removing_a_send_row_or_its_source_mute_curve_lands_on_the_fresh_plan_on_the_c_abi` and host-web + `removing_a_send_row_or_its_source_mute_curve_lands_alike_on_both_hosts` (both red on `rr`). +- MAJOR-4: graph `a_matrix_step_keeps_its_jump_length_under_a_gain_ride` (red: block 16, the + 960-sample jump cut by the gain's grid) and automation + `a_step_keeps_its_jump_length_while_another_curve_rides_on_a_held_grid_output` (ported). +- Reviewer's missing tests (graph): `a_seek_landing_inside_a_column_jump_ends_the_hold`, + `a_hand_mute_and_a_gate_fade_together`, + `a_source_mute_added_while_a_move_is_in_flight_fades_its_column_at_once`. + +Sizes (x86-64, measured): `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 944 -> 1,000 (the dropped roles and two +owed slots), `GraphFollowRouteControlBinding` 184 -> 856 (it carries the column tables from attach +to bind); `GraphFollowRouteSource` 120 and `ComposeCursor` 104 unchanged. Charges read `size_of`, +so no pinned number moved. Realtime policy: 171 marked regions in 35 files (unchanged). +`scripts/check-graph-policy.sh`: PASS. diff --git a/crates/automation/src/compose.rs b/crates/automation/src/compose.rs index d6fabd67b..b34487464 100644 --- a/crates/automation/src/compose.rs +++ b/crates/automation/src/compose.rs @@ -22,7 +22,8 @@ //! retargets the output, and when the caller retargets it ([`Composed::retarget`]: a record's //! ramp, a gate change of the cell), every value cursor defers its jump to the first free sample //! of that ramp ([`CellState::defer_jump`], #1711 D1). Until it fires, the value cursor's grid -//! events are held: the heard values do not move and the output does not retarget, so no grid +//! events are held: they update the heard values (a gate event or record composes them) but the +//! output does not retarget, so no grid //! event cuts the column jump short. At the jump's end each value cursor whose curve moved jumps //! over its own jump length to the curve at the jump's completion (the deferred jump's rule, change //! only, or always with a held event outstanding), and the coincident landings take the longest @@ -109,7 +110,7 @@ pub struct ComposeCursor { state: CellState, /// The jump length of a [`crate::JumpLength::Block`] cell for this block. jump: u32, - /// The value the output has heard from this curve: its last event that was not held. + /// The value the output has heard from this curve: its last event, held or not (R19-3). heard: f32, // The walk's scratch, meaningful only inside [`Composed::walk`]. /// The state before the pull that produced `next`, and the offset that pull started at. @@ -216,6 +217,9 @@ pub struct Composed { completion: Completion, /// The block begun by [`Self::start`] and its seek mode, until its events end. block: Option<(NodeSpan, SeekMode)>, + /// [`Self::with_held_grid`]: every output retarget holds each value cursor's grid inside the + /// retarget's ramp, as one cell holds its grid inside its own ramp (#1655 V2, R19-2). + held_grid: bool, } impl Composed { @@ -273,8 +277,25 @@ impl Composed { target: None, completion, block: None, + held_grid: false, }) } + + /// The output with the one-cell hold rule on its composed value (#1655 V2, R19-2: a member + /// fader channel over its own curve and the reaching VCA curves): every retarget of the output + /// (a grid or jump event, a ramped landing) reports its ramp to each value cursor whose own + /// ramp in flight completes earlier ([`CellState::hold_until`]), so a cursor's grid event + /// whose ramp would complete inside it is held and one that completes later restarts it, by + /// the cell's own grid rule. A step of one curve then keeps its jump length while another + /// curve rides (and the reverse); coincident events already take the longest ramp (R19-4). + /// Without it (a route column, R19-3) a value jump leaves the other value cursors free. With + /// one cursor the hold is the cursor's own ramp, so the output's events are still the cell's + /// [`CellEvents`]. Control side. + #[must_use] + pub fn with_held_grid(mut self) -> Self { + self.held_grid = true; + self + } } // REALTIME_POLICY_BEGIN @@ -349,6 +370,26 @@ impl Composed { self.state.seed_target(0.0); } + /// Forgets the output's target: its next grid or jump event retargets whatever its value (the + /// one-cell rule of a fresh state, for a stage that only remembered a gain, #1638 D2). The + /// ramp in flight is kept. + pub fn clear_target(&mut self) { + self.target = None; + } + + /// The output's own event state (its ramp in flight and held flag), for a carry. + #[must_use] + pub const fn output_state(&self) -> &CellState { + &self.state + } + + /// Sets the output's own event state and target (a carry of a predecessor's output, or of a + /// single cell's state with no target); the cursors are untouched. + pub fn set_output(&mut self, state: CellState, target: Option) { + self.state = state; + self.target = target; + } + /// Seeds the output's target with the composition of the heard values (preparation). pub fn seed(&mut self, compose: impl FnOnce(Heard<'_>) -> T) { let value = compose(self.heard(Source::Heard)); @@ -432,6 +473,7 @@ impl Composed { self.target = other.target; self.completion = other.completion; self.block = other.block; + self.held_grid = other.held_grid; true } @@ -511,6 +553,15 @@ impl Composed { } else { None }; + // `with_held_grid`: the completion of the ramp this offset retargets the output over + // (a landing too), which every value cursor holds its grid inside. + let hold = event + .filter(|event| self.held_grid && !event.held && event.ramp > 0) + .map(|event| crate::completion_of(self.completion, time, event.ramp)); + let completion = self.completion; + let short = |state: &CellState, hold: i64| { + state.free_after(completion).is_none_or(|free| free <= hold) + }; let next = offset.saturating_add(1); for cursor in self.cursors.iter_mut() { cursor.shown = cursor.heard; @@ -530,14 +581,34 @@ impl Composed { && pending.is_some_and(|at| at > time && at < free) }); let defer = if column_jump { free } else { extend }; - if let (Some(free), true) = (defer, value) { + // The hold reaches a cursor whose own ramp in flight completes earlier: for one + // that pulled ahead, the state its pull started from. + let held = hold.filter(|&hold| { + value + && short( + if cursor.consumed { + &cursor.state + } else { + &cursor.before + }, + hold, + ) + }); + if value && (defer.is_some() || held.is_some()) { if !cursor.consumed { // The cursor pulled past this offset before the column ramp changed: - // replay its pull up to here (it gave no event there), then defer and - // pull again. + // replay its pull up to here (it gave no event there), then defer (or + // hold) and pull again. replay(cursor, program, &span, mode, next); } - cursor.state.defer_jump(free); + if let Some(hold) = held + && short(&cursor.state, hold) + { + cursor.state.hold_until(hold); + } + if let Some(free) = defer { + cursor.state.defer_jump(free); + } pull(cursor, program, &span, mode, next, frames); } else if cursor.consumed { pull(cursor, program, &span, mode, next, frames); @@ -627,7 +698,9 @@ struct Merged { held_ramp: u32, } -/// Take every cursor's event at `offset`: a held event is shown, any other is heard. +/// Take every cursor's event at `offset`: every event is heard (R19-3: a held grid event updates +/// the heard values and does not retarget the output, so a later gate event or record composes +/// the grid's value, U2 fold 1), and a held one is also shown to the held-event rule. fn merge(cursors: &mut [ComposeCursor], offset: u32) -> Merged { let mut merged = Merged::default(); for cursor in cursors.iter_mut() { @@ -638,12 +711,12 @@ fn merge(cursors: &mut [ComposeCursor], offset: u32) -> Merged { cursor.consumed = true; cursor.moved = !event.held; cursor.shown = event.value; + cursor.heard = event.value; if event.held { merged.held = true; merged.held_ramp = merged.held_ramp.max(event.ramp); continue; } - cursor.heard = event.value; merged.ramp = merged.ramp.max(event.ramp); match event.kind { EventKind::Set => merged.set = true, diff --git a/crates/automation/src/compose/tests.rs b/crates/automation/src/compose/tests.rs index ab653cb25..90485b67f 100644 --- a/crates/automation/src/compose/tests.rs +++ b/crates/automation/src/compose/tests.rs @@ -566,3 +566,64 @@ fn an_unchanged_value_jump_keeps_the_gate_deferral() { [(1_000, EventKind::Jump, 480), (1_480, EventKind::Jump, 256)] ); } + +fn summed(heard: Heard<'_>) -> f32 { + heard.value(0) + heard.value(1) +} + +/// #1655 V2 (R19-2), `with_held_grid`: a step of one value curve keeps its jump length while +/// another rides, both ways round. The step at 1,000 jumps over 480; the riding curve's grid +/// points whose ramps would end inside that jump (1,024 to 1,408) are held, and the first one +/// whose ramp ends after it (1,472) retargets again, by the one-cell rule. Without the rule (a +/// route column, R19-3) the riding curve's grid point at 1,024 cuts the jump short. +/// +/// Red if a grid event inside another curve's jump retargets a held-grid output, or if the hold +/// outlasts the jump. +#[test] +fn a_step_keeps_its_jump_length_while_another_curve_rides_on_a_held_grid_output() { + let ride = [seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let step = [ + seg(0, 1_000, -6.0, -6.0, Shape::Step), + seg(1_000, 9_000, -12.0, -12.0, Shape::Step), + ]; + for order in [[&ride[..], &step[..]], [&step[..], &ride[..]]] { + let program = + ProgramBuilder::build(&[fader_cell(order[0]), fader_cell(order[1])]).expect("program"); + let bind = |held: bool| { + let composed = Composed::::new( + &program, + &[(0, CursorKind::Value), (1, CursorKind::Value)], + Completion::End, + ) + .expect("bind"); + let mut composed = if held { + composed.with_held_grid() + } else { + composed + }; + composed.seed(summed); + for cursor in composed.cursors_mut() { + cursor.set_jump(480); + } + composed + }; + let after_step = |events: &[(u32, EventKind, u32)]| { + events + .iter() + .copied() + .filter(|&(offset, _, _)| offset >= 1_000) + .take(2) + .collect::>() + }; + let (held, _) = retargets(&program, &mut bind(true), summed, 1_000); + assert_eq!( + after_step(&held), + [(1_000, EventKind::Jump, 480), (1_472, EventKind::Grid, 64)] + ); + let (free, _) = retargets(&program, &mut bind(false), summed, 1_000); + assert_eq!( + after_step(&free), + [(1_000, EventKind::Jump, 480), (1_024, EventKind::Grid, 64)] + ); + } +} diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 3d6f656ef..27979dfd4 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -12074,6 +12074,71 @@ fn an_output_route_entry_is_refused_on_the_c_abi() { assert_eq!(rig.summary(), before, "no revision"); } +/// #1654 U2 fold 1, MAJOR-3 on the C ABI. The send `eq0-b` keeps a flat `gain_db` ride at -6 dB +/// (its table continues) and a rebuild removes either its flat `rr` entry at 0.5 or `eq0`'s mute +/// entry (held muted): the send stays a following route, the join writes no record, and no cursor +/// hears the removed curve. The rebuild completes `EXACT`; from the first block boundary after the +/// landing over the removed row's own length (`SendMatrix`, 240, for `rr`; the mute length, 480, +/// for the mute curve) the send's plane is a fresh plan of the committed document, session-seeked +/// to the adoption block. +/// +/// Test value: red if a removed row or source mute curve of a send that stays seven-word leaves +/// the send on the removed curve's last value. +#[test] +fn removing_a_send_row_or_its_source_mute_curve_lands_on_the_fresh_plan_on_the_c_abi() { + use session::ParameterUnit::{Db, Linear}; + let gain = || route_entry("send-gain", "eq0-b", 1, send_flat(-6.0, Db, false)); + let with_rr = send_rows_document( + vec![ + gain(), + route_entry("send-rr", "eq0-b", 6, send_flat(0.5, Linear, false)), + ], + |_| {}, + ); + let with_mute = with_model(&follow_document(true, false), |model| { + model.automation[0].segments = send_flat(1.0, Linear, true); + model.automation.push(gain()); + }); + for (label, document, removed, length) in [ + ("rr", &with_rr, "send-rr", 240_usize), + ("the source mute", &with_mute, "eq0-mute", 480), + ] { + let mut rig = Rig::new(document); + for _ in 0..4 { + rig.step(); + } + let (adoption, live) = rebuild_and_adopt( + &mut rig, + &[SessionEdit::RemoveAutomation { + automation_id: StableId::parse(removed).expect("automation ID"), + }], + ADOPTED_BLOCKS, + ); + assert_eq!( + rig.current_follow_route_cells(), + vec![0], + "{label}: still one seven-word send, and the join writes no record" + ); + let quantum = rig.quantum; + let fresh = seeked_reference(&rig.snapshot(), adoption, ADOPTED_BLOCKS); + let (send, fresh) = ( + plane_bits(&live, 1, quantum), + plane_bits(&fresh, 1, quantum), + ); + assert_ne!( + send[..64], + fresh[..64], + "{label}: the send lands, from the old value" + ); + let landed = length.div_ceil(quantum) * quantum; + assert_eq!( + send[landed..], + fresh[landed..], + "{label}: a fresh plan after the landing" + ); + } +} + /// #1654 R19-5 on the C ABI. On [`send_ride_document`], a live send move with its own 4,000 /// samples (the hand gain offset `+3`, or a D6 `matrix_lr` edit) is in flight when a rebuild /// changes the gain ride's table (the carried cursor is repositioned). The rebuild completes diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 13f2e9394..47d552dea 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1600,6 +1600,9 @@ pub struct GraphFollowRouteControlBinding { /// The source strip's mute cells, the route's own row cells, `a(tap)`, the jump rule, the /// gain law and the seeded flags. pub source: GraphFollowRouteSource, + /// The route op's cursor tables, allocated at attach so a failed allocation is a typed + /// refusal ([`GraphRouteControlError::CursorTable`]); bind moves them into the op. + pub(crate) columns: runtime::FollowColumns, } /// The control-plane half of one following route's seven-word cell (#1652 D1): the counterpart of @@ -1680,17 +1683,21 @@ pub(crate) fn follow_route_control_pair( activity_bytes: u64, activity_largest: u64, activity_routes: u64, -) -> ( - GraphFollowRouteControlBinding, - GraphFollowRouteControlProducer, -) { +) -> Result< + ( + GraphFollowRouteControlBinding, + GraphFollowRouteControlProducer, + ), + automation::ComposeError, +> { + let columns = runtime::FollowColumns::bind(&source)?; let lane = std::sync::Arc::new(FollowRouteLane { cell: engine::realtime::LatestCell::new(), dirty: engine::realtime::DirtyWord::new(), }); let automated = source.flags().automated_set(); let (cursor_bytes, cursor_largest) = runtime::follow_route_cursor_bytes(&source); - ( + Ok(( GraphFollowRouteControlBinding { node: GraphNodeId::Route { route_id: route_id.clone(), @@ -1702,6 +1709,7 @@ pub(crate) fn follow_route_control_pair( superseded: superseded.clone(), }), source, + columns, }, GraphFollowRouteControlProducer { route_id: route_id.as_str().into(), @@ -1715,7 +1723,7 @@ pub(crate) fn follow_route_control_pair( cursor_bytes, cursor_largest, }, - ) + )) } /// Why [`PreparedGraphPlan::attach_route_controls`] refused. @@ -1726,6 +1734,9 @@ pub enum GraphRouteControlError { /// A route into a submix is also a node the plan requires a processor for; a live route is /// never a [`GraphNodeBinding`] (its op would not mix). BoundRoute, + /// A following route's cursor tables were refused (#1654 T3: an allocation failure; a + /// source's cells are always its program's). + CursorTable(automation::ComposeError), } /// The bytes bind allocates for one live route's render-side owner, its boxed state (issue #1220 @@ -2913,7 +2924,9 @@ impl PreparedGraphPlan { /// 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. + /// Refused as [`Self::attach_route_controls`] is, and with + /// [`GraphRouteControlError::CursorTable`] when a following route's cursor tables cannot be + /// allocated (nothing is attached then). pub fn attach_route_controls_following( &mut self, superseded: &engine::realtime::LiveValuesSuperseded, @@ -3003,7 +3016,8 @@ impl PreparedGraphPlan { activity_bytes, activity_largest, u64::try_from(self.routes.len()).unwrap_or(u64::MAX), - ); + ) + .map_err(GraphRouteControlError::CursorTable)?; follow_bindings.push(binding); follow_producers.push(producer); continue; @@ -11043,7 +11057,8 @@ 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, 0); + follow_route_control_pair(&route_id("send"), &superseded, one_lane_source(), 0, 0, 0) + .expect("tables"); assert_eq!(&*producer.route_id, "send"); assert_eq!( binding.node, @@ -11121,6 +11136,7 @@ mod follow_route_tests { activity.1, ACTIVITY_ROUTES, ) + .expect("tables") .1 }) .collect(); diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index 2a9a1f29a..5daaa3f6e 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -1384,16 +1384,18 @@ impl ColumnRamps { ) else { return; }; - if indices - .iter() - .all(|&index| target[index].to_bits() == ramp.target[index].to_bits()) - { + if indices.iter().all(|&index| { + target.get(index).map(|value| value.to_bits()) + == ramp.target.get(index).map(|value| value.to_bits()) + }) { return; } let at = ramp.coefficients_at(position); let mut start = target; for &index in indices { - start[index] = at[index]; + if let (Some(slot), Some(&value)) = (start.get_mut(index), at.get(index)) { + *slot = value; + } } if let (Some(ramp), Some(position)) = (self.ramps.get_mut(column), self.positions.get_mut(column)) @@ -1578,26 +1580,61 @@ struct RouteColumn { probe: automation::Composed, roles: ColumnRoles, placement: [Placement; ROLES], + /// A landing a carry owes the column (U2 fold 1, MAJOR-3): the cursor whose deferred jump + /// carries it and the jump length it lands over (a removed row's own length), until that + /// deferral fires. + owed: Option<(usize, u32)>, } impl RouteColumn { /// `column`'s walk under `source`, or `None` when the column hears no curve. Control side: - /// it allocates the two tables ([`follow_route_cursor_bytes`]). - fn bind(source: &crate::GraphFollowRouteSource, column: usize) -> Option { + /// it allocates the two tables ([`follow_route_cursor_bytes`]). Each table holds its grid + /// inside every column retarget (K1 `with_held_grid`, U2 fold 1 MAJOR-4: a value step keeps + /// its jump length while another curve rides). + /// + /// # Errors + /// + /// The tables' refusal: the source holds only cells of its program + /// (`GraphFollowRouteSource::new`), so only an allocation failure. + fn bind( + source: &crate::GraphFollowRouteSource, + column: usize, + ) -> Result, automation::ComposeError> { let (cells, count, roles) = column_cells(source, column); - let cells = cells.get(..count).filter(|cells| !cells.is_empty())?; + let Some(cells) = cells.get(..count).filter(|cells| !cells.is_empty()) else { + return Ok(None); + }; let table = || { - // The source holds only cells of its program (`GraphFollowRouteSource::new`), so the - // one refusal left is an allocation failure, which bind treats as every other. automation::Composed::new(&source.program, cells, automation::Completion::End) - .expect("a following route's cursor table allocates") + .map(automation::Composed::with_held_grid) }; - Some(Self { - walk: table(), - probe: table(), + Ok(Some(Self { + walk: table()?, + probe: table()?, roles, placement: [Placement::Continued; ROLES], - }) + owed: None, + })) + } +} + +/// A following route's column walks ([`RouteColumn`]), allocated when its lane is attached and +/// moved into its route op at bind (U2 fold 1: a failed allocation is a typed refusal). +pub(crate) struct FollowColumns([Option; 2]); + +impl FollowColumns { + /// Both columns' walks under `source`. Control side. + /// + /// # Errors + /// + /// A table's refusal ([`RouteColumn::bind`]). + pub(crate) fn bind( + source: &crate::GraphFollowRouteSource, + ) -> Result { + Ok(Self([ + RouteColumn::bind(source, 0)?, + RouteColumn::bind(source, 1)?, + ])) } } @@ -1614,6 +1651,9 @@ struct FollowCursors { jumps: crate::GraphRouteJumpRule, gain_law: crate::GraphRouteGainLaw, continued: [bool; crate::GRAPH_ROUTE_ROWS], + /// Per column, the roles the predecessor's column heard and this plan's does not (a removed + /// row or source mute curve), set by a carry and read by its first block (U2 fold 1, MAJOR-3). + dropped: [[bool; ROLES]; 2], /// How the route's next block begins: fresh, carried or running. start: FollowStart, } @@ -1675,22 +1715,25 @@ impl LiveFollowRoute { /// in dB, not a linear gain: the bound word is the source's seeded offset /// ([`crate::GraphFollowRouteSource::gain_offset`]), and `transform.gain` (the compiler's /// static linear gain, a fallback render never hears) is replaced by it (#1654 T4 note). + /// `columns` are the source's walks ([`FollowColumns::bind`]). pub(crate) fn new( mut transform: crate::RouteTransform, control: Box, source: &crate::GraphFollowRouteSource, + columns: FollowColumns, ) -> Self { if source.flags.automated_gain { transform.gain = source.gain_offset; } let mut follow = FollowCursors { program: std::sync::Arc::clone(&source.program), - columns: [0, 1].map(|column| RouteColumn::bind(source, column)), + columns: columns.0, silent: [false; 2], arrival: source.arrival, jumps: source.jumps, gain_law: source.gain_law, continued: source.continued, + dropped: [[false; ROLES]; 2], start: FollowStart::Fresh, }; let composition = Composition { @@ -1881,6 +1924,7 @@ impl FollowCursors { cursor.set_jump(lengths.for_row(ColumnRoles::row(role))); } } + column.apply_owed(lengths); } let idle = FollowBlock { spans: [None; 2], @@ -2019,6 +2063,62 @@ impl FollowCursors { } } +impl RouteColumn { + /// The owed landing's length on its cursor while its deferral is pending (the longer of the + /// cursor's own row length and the owed one, R19-4); once it has fired the debt is spent. + fn apply_owed(&mut self, lengths: crate::GraphRouteJumpLengths) { + let Some((index, length)) = self.owed else { + return; + }; + let roles = self.roles; + let own = (0..ROLES) + .find(|&role| roles.of(role) == Some(index)) + .map_or(0, |role| lengths.for_row(ColumnRoles::row(role))); + match self.walk.cursor_mut(index) { + Some(cursor) if cursor.state().deferred_jump().is_some() => { + cursor.set_jump(core::cmp::max(own, length)); + } + _ => self.owed = None, + } + } + + /// Owe the column a landing at node time `free` over `length` (U2 fold 1, MAJOR-3): the + /// composition moved with no cursor whose curve moved (a removed row or source mute curve). + /// The first value cursor (else the first gate) is placed at `at` with no target and defers + /// its jump to `free`, so its jump there emits whatever its curve, and the column composes + /// and lands; until then its grid is held (R19-3). + fn owe_landing( + &mut self, + program: &automation::Program, + lengths: crate::GraphRouteJumpLengths, + at: i64, + free: i64, + length: u32, + ) { + let kinds = [automation::CursorKind::Value, automation::CursorKind::Gate]; + let Some(index) = kinds.iter().find_map(|&kind| { + self.walk + .cursors() + .iter() + .position(|cursor| cursor.kind() == kind) + }) else { + return; + }; + let Some(cursor) = self.walk.cursor_mut(index) else { + return; + }; + let Some(cell) = program.cell(cursor.cell() as usize) else { + return; + }; + let mut state = automation::CellState::new(); + state.reposition(&cell, at); + state.defer_jump(free); + *cursor.state_mut() = state; + self.owed = Some((index, length)); + self.apply_owed(lengths); + } +} + fn for_placed_values(walk: &mut RouteColumn, mut each: impl FnMut(&mut automation::ComposeCursor)) { for role in 0..ROLES { let placed = walk @@ -2116,13 +2216,28 @@ impl LiveFollowRoute { self.mute = mute; self.follow.silent = from.map_or([mute; 2], |from| from.follow.silent); let continued = self.follow.continued; - for (column, walk) in self.follow.columns.iter_mut().enumerate() { - let Some(walk) = walk else { - continue; - }; + for (column, (walk, dropped)) in self + .follow + .columns + .iter_mut() + .zip(&mut self.follow.dropped) + .enumerate() + { let previous = from .and_then(|from| from.follow.columns.get(column)) .and_then(Option::as_ref); + // The roles the predecessor's column heard that this column does not (MAJOR-3). + for (role, slot) in dropped.iter_mut().enumerate() { + let had = previous.is_some_and(|previous| previous.roles.of(role).is_some()); + let has = walk + .as_ref() + .is_some_and(|walk| walk.roles.of(role).is_some()); + *slot = had && !has; + } + let Some(walk) = walk else { + continue; + }; + walk.owed = None; for role in 0..ROLES { let Some(index) = walk.roles.of(role) else { continue; @@ -2166,12 +2281,18 @@ impl LiveFollowRoute { /// `at`: each cursor that does not continue is placed there, a gate with the curve's value (no /// `Set`), a value with its curve's value heard and its state repositioned (its target still /// the one the column heard). Then each column composes and compares with the adopted ramp's - /// target: equal bits leave the ramp (the placed values become the cursors' targets); a gate + /// target: equal bits leave the ramp (the placed values become the cursors' targets, and with + /// a column ramp in flight they defer their jumps to its first free sample, R19-5); a gate /// that moved retargets the column at once over the mute length, a column jump (R19-3); a /// value that moved alone defers the jump of the column's placed value cursors to the column /// ramp's first free sample, at once when it is settled (#1711 D1), where each one whose curve - /// moved jumps over its own row's length. A column with no curve compares its cell's target - /// over the mute length, as before. + /// moved jumps over its own row's length. A column that no longer hears a row or source mute + /// curve of its predecessor and composes otherwise (U2 fold 1, MAJOR-3: no cursor's curve + /// carries that move) lands over the removed row's own length (the mute length for a mute + /// curve, the longer one with a coincident moved value, R19-4): at once when settled, a column + /// jump; else at the column ramp's first free sample, a landing owed on one of its cursors + /// ([`RouteColumn::owe_landing`]). A column with no curve compares its cell's target over the + /// mute length (a removed row's length when it lost one), as before. fn place_carried(&mut self, at: i64) { let Self { ramps, @@ -2187,18 +2308,42 @@ impl LiveFollowRoute { flags: *flags, law: follow.gain_law, }; - let jump = block.lengths.mute; + let lengths = block.lengths; + let jump = lengths.mute; for column in 0..COLUMNS.len() { let adopted = column_entries(ramps.target(), column); + // The longest own length of the rows and curves the column no longer hears + // (MAJOR-3): a removed row lands over its own length, a removed mute curve over the + // mute length. + let dropped = follow.dropped.get(column).map_or(0, |dropped| { + (0..ROLES) + .filter(|&role| dropped.get(role).copied().unwrap_or(false)) + .map(|role| lengths.for_row(ColumnRoles::row(role))) + .max() + .unwrap_or(0) + }); + let has_dropped = follow + .dropped + .get(column) + .is_some_and(|dropped| dropped.iter().any(|&role| role)); let Some(walk) = follow.columns.get_mut(column).and_then(Option::as_mut) else { let target = column_target(column, ColumnRoles::default(), composition, |_| 0.0); - ramps.retarget_column(column, with_column(ramps.target(), column, target.0), jump); + let length = if has_dropped { dropped } else { jump }; + ramps.retarget_column( + column, + with_column(ramps.target(), column, target.0), + length, + ); if let Some(silent) = follow.silent.get_mut(column) { *silent = target.1; } continue; }; let mut gate_moved = false; + let mut any_placed = false; + // The longest own length of a placed value whose curve moved from the value the + // column heard (a landing coincident with a removed row's, R19-4). + let mut moved = 0_u32; for role in 0..ROLES { let placed = walk .placement @@ -2210,11 +2355,13 @@ impl LiveFollowRoute { let Some(cursor) = walk.walk.cursor_mut(index) else { continue; }; + let value_row = cursor.kind() == automation::CursorKind::Value; + any_placed |= value_row; + let own = lengths.for_row(ColumnRoles::row(role)); // A new value cursor was placed before the cell (`place_new_values`); placing it // again would drop the deferral a join record's column jump gave it. - if walk.placement.get(role) == Some(&Placement::New) - && cursor.kind() == automation::CursorKind::Value - { + if walk.placement.get(role) == Some(&Placement::New) && value_row { + moved = core::cmp::max(moved, own); continue; } let Some(cell) = follow.program.cell(cursor.cell() as usize) else { @@ -2225,6 +2372,8 @@ impl LiveFollowRoute { if cursor.kind() == automation::CursorKind::Gate { gate_moved |= (cursor.heard() != 0.0) != (value != 0.0); cursor.state_mut().seed_target(value); + } else if cursor.state().target().map(f32::to_bits) != Some(value.to_bits()) { + moved = core::cmp::max(moved, own); } cursor.set_heard(value); } @@ -2232,26 +2381,62 @@ impl LiveFollowRoute { let target = walk.walk.compose_heard(|heard| { column_target(column, roles, composition, |index| heard.value(index)) }); + let remaining = ramps.remaining(column); + let free = at.saturating_add(i64::from(remaining)); if same_entries(adopted, target.0) { for_placed_values(walk, |cursor| { let heard = cursor.heard(); cursor.state_mut().seed_target(heard); + // R19-5 (MAJOR-2): a move in flight keeps its length; the repositioned + // cursor waits for its end, where it lands if its curve moved. + if remaining > 0 { + cursor.state_mut().defer_jump(free); + } }); walk.walk.seed_target(target); if let Some(silent) = follow.silent.get_mut(column) { *silent = target.1; } } else if gate_moved { - ramps.retarget_column(column, with_column(ramps.target(), column, target.0), jump); - walk.walk.retarget(target, at, jump); + // Coincident with a removed row's landing, the longer length (R19-4). + let length = core::cmp::max(jump, dropped); + ramps.retarget_column( + column, + with_column(ramps.target(), column, target.0), + length, + ); + walk.walk.retarget(target, at, length); + if let Some(silent) = follow.silent.get_mut(column) { + *silent = target.1; + } + } else if (has_dropped || !any_placed) && remaining == 0 { + // MAJOR-3: the column moved with no cursor whose curve moved (a removed row or + // source mute curve): settled, it lands at once over the removed row's length, a + // column jump (R19-3). + // A placed value whose curve moved lands in it, so it owes no landing of its own. + let length = core::cmp::max(if has_dropped { dropped } else { jump }, moved); + for_placed_values(walk, |cursor| { + let heard = cursor.heard(); + cursor.state_mut().seed_target(heard); + }); + ramps.retarget_column( + column, + with_column(ramps.target(), column, target.0), + length, + ); + walk.walk.retarget(target, at, length); if let Some(silent) = follow.silent.get_mut(column) { *silent = target.1; } } else { let silent = follow.silent.get(column).copied().unwrap_or(false); walk.walk.seed_target((adopted, silent)); - let free = at.saturating_add(i64::from(ramps.remaining(column))); for_placed_values(walk, |cursor| cursor.state_mut().defer_jump(free)); + if has_dropped || !any_placed { + // MAJOR-3 with a move in flight: the landing is owed at its end. + let length = if has_dropped { dropped } else { jump }; + walk.owe_landing(&follow.program, lengths, at, free, length); + } } } *mute = follow.silent[0] && follow.silent[1]; @@ -7632,6 +7817,7 @@ pub(crate) struct RuntimeParts { ( Box, crate::GraphFollowRouteSource, + FollowColumns, ), >, pub(crate) bindings: BTreeMap>>, @@ -7798,7 +7984,12 @@ impl RuntimeParts { ) -> Self { self.follow_route_controls = controls .into_iter() - .map(|binding| (binding.node, (binding.control, binding.source))) + .map(|binding| { + ( + binding.node, + (binding.control, binding.source, binding.columns), + ) + }) .collect(); self } @@ -7850,11 +8041,11 @@ 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`). - if let Some((control, source)) = self.follow_route_controls.remove(node) { + if let Some((control, source, columns)) = 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, + transform, control, &source, columns, ))) } else { match self.route_controls.remove(node) { @@ -18314,11 +18505,13 @@ mod follow_route_op_tests { 0, 0, 0, - ); + ) + .expect("tables"); let route = Box::new(LiveFollowRoute::new( transform, binding.control, &binding.source, + binding.columns, )); let (gate, pin) = RevisionGate::pair(0); Self { @@ -19600,11 +19793,13 @@ mod send_automation_tests { 0, 0, 0, - ); + ) + .expect("tables"); let route = Box::new(LiveFollowRoute::new( transform, binding.control, &binding.source, + binding.columns, )); let (gate, pin) = RevisionGate::pair(0); Self { @@ -20212,4 +20407,438 @@ mod send_automation_tests { ); } } + + /// U2 fold 1, MAJOR-1 (R19-3: a held grid event updates the heard values): the send's own mute + /// closes at 1,000 (a column jump over the mute length, 200, to 1,200) and reopens at 1,100, + /// inside it, while the gain rides. The gain's grid events at 1,024 and 1,088 are held, but + /// heard: the reopen aims at the gain of the grid event at 1,088 (the curve at 1,151). + /// + /// Red if a held grid event is shown only, so the reopen composes the gain heard before the + /// close (the curve at 1,023). + #[test] + fn a_reopen_inside_a_gate_jump_composes_the_held_grid_values() { + let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let curves = Curves { + gain: Some(ride.clone()), + mute: Some(vec![ + seg(0, 1_000, 0.0, 1.0, Shape::Step), + seg(1_000, 1_100, 1.0, 0.0, Shape::Step), + ]), + ..Curves::default() + }; + let mut send = Send::new(&curves, OPEN); + let by_block = ramps_by_block(&mut send, 20); + let reopen = by_block[1_100 / QUANTUM]; + let heard = open(OPEN, law(automation::value_at(&ride, 1_151), 0.0)); + for column in 0..2 { + assert!( + aims(&reopen[column], heard, 200, column), + "the reopen, column {column}: {:?}", + reopen[column].target + ); + } + } + + /// U2 fold 1, MAJOR-2 (R19-5 with equal bits at the swap): a hand offset (+3 dB over 2,000 from + /// 1,152) is in flight at a swap (1,280) whose gain table stays at -6 dB until 2,000 and then + /// ramps. The composition at the swap equals the adopted target, so nothing lands then, but + /// the repositioned gain still waits for the move's end (3,152): its ramp's grid does not cut + /// the move, and at 3,152 it lands over the gain length. + /// + /// Red if the equal branch leaves the repositioned cursor free (the ramp's grid replaces the + /// 2,000-sample move from 2,000). + #[test] + fn a_hand_move_in_flight_at_an_equal_swap_keeps_its_length() { + let before = Curves { + gain: Some(flat(-6.0)), + ..Curves::default() + }; + let table = vec![ + seg(0, 2_000, -6.0, -6.0, Shape::Linear), + seg(2_000, 8_000, -6.0, -18.0, Shape::Linear), + ]; + let after = Curves { + gain: Some(table.clone()), + ..Curves::default() + }; + let moved = RouteTransform { gain: 3.0, ..OPEN }; + let mut predecessor = Send::new(&before, OPEN); + for index in 0..18_u64 { + predecessor.run(index * 64); + } + predecessor.write(moved, 2_000); + for index in 18..20_u64 { + predecessor.run(index * 64); + } + let record = predecessor.route.ramps.ramps; + let mut successor = Send::new(&after, moved); + swap(&mut successor, &mut predecessor); + for index in 20..49_u64 { + successor.run(index * 64); + assert_eq!( + successor.route.ramps.ramps, record, + "block {index}: the move runs" + ); + } + successor.run(49 * 64); + let target = open(OPEN, law(automation::value_at(&table, 3_152 + 479), 3.0)); + for column in 0..2 { + let ramp = successor.route.ramps.ramps[column]; + assert!(aims(&ramp, target, 480, column), "column {column}"); + assert_eq!(successor.route.ramps.positions[column], 48); + } + } + + /// U2 fold 1, MAJOR-3: a send whose gain row continues and whose `ll` row (or the source's + /// left mute curve) is removed at a swap, settled. The removed row has no cursor and no join + /// record moves the cell, so the carry itself lands the column on the static value at the + /// successor's first sample, over the removed row's own length (`SendMatrix` 960 for `ll`, + /// the mute length 200 for a mute curve); the other column keeps its ramp. + /// + /// Red if the column stays on the removed curve's last value. + #[test] + fn a_removed_send_row_lands_the_column_on_its_static_value() { + let gain = Some(flat(-6.0)); + let cases = [ + ( + Curves { + gain: gain.clone(), + matrix: [Some(flat(0.5)), None, None, None], + ..Curves::default() + }, + 960, + ), + ( + Curves { + source: [Some(flat(1.0)), None], + gain: gain.clone(), + ..Curves::default() + }, + 200, + ), + ]; + let after = Curves { + gain, + ..Curves::default() + }; + let target = open(OPEN, law(-6.0, 0.0)); + for (index, (before, length)) in cases.iter().enumerate() { + let mut predecessor = Send::new(before, OPEN); + for block in 0..20_u64 { + predecessor.run(block * 64); + } + let kept = predecessor.route.ramps.ramps[1]; + let mut successor = + Send::continuing(&after, OPEN, [true, false, false, false, false, false]); + swap(&mut successor, &mut predecessor); + successor.run(20 * 64); + let ramp = successor.route.ramps.ramps[0]; + assert!( + aims(&ramp, target, *length, 0), + "case {index}: {:?} over {}", + ramp.target, + ramp.length + ); + assert_eq!(successor.route.ramps.positions[0], 64, "case {index}"); + assert_eq!(successor.route.ramps.ramps[1], kept, "case {index}"); + } + } + + /// U2 fold 1, MAJOR-3 with a live move in flight: the `ll` row is removed at a swap (1,280) + /// while a hand offset (+3 dB over 2,000 from 1,152) runs. The move keeps its length; at its + /// end (3,152) the column lands on the static `ll` over `SendMatrix` (960). + /// + /// Red if the removal never lands, or cuts the move. + #[test] + fn a_removed_send_row_lands_after_a_move_in_flight() { + let before = Curves { + gain: Some(flat(-6.0)), + matrix: [Some(flat(0.5)), None, None, None], + ..Curves::default() + }; + let after = Curves { + gain: Some(flat(-6.0)), + ..Curves::default() + }; + let moved = RouteTransform { gain: 3.0, ..OPEN }; + let mut predecessor = Send::new(&before, OPEN); + for index in 0..18_u64 { + predecessor.run(index * 64); + } + predecessor.write(moved, 2_000); + for index in 18..20_u64 { + predecessor.run(index * 64); + } + let record = predecessor.route.ramps.ramps; + let mut successor = + Send::continuing(&after, moved, [true, false, false, false, false, false]); + swap(&mut successor, &mut predecessor); + for index in 20..49_u64 { + successor.run(index * 64); + assert_eq!( + successor.route.ramps.ramps, record, + "block {index}: the move runs" + ); + } + successor.run(49 * 64); + let ramp = successor.route.ramps.ramps[0]; + assert!( + aims(&ramp, open(OPEN, law(-6.0, 3.0)), 960, 0), + "{:?} over {}", + ramp.target, + ramp.length + ); + assert_eq!(successor.route.ramps.positions[0], 48); + assert_eq!(successor.route.ramps.ramps[1], record[1]); + } + + /// U2 fold 1, MAJOR-4 (K1: a value jump keeps its length, `with_held_grid`): `ll` steps at + /// 1,000 and jumps over `SendMatrix` (960, to 1,959) while the gain rides. The gain's grid + /// events whose ramps end inside the jump are held; the first whose ramp ends after it + /// (1,920) retargets the column again over the grid ramp. + /// + /// Red if the gain's grid at 1,024 cuts the matrix step's jump. + #[test] + fn a_matrix_step_keeps_its_jump_length_under_a_gain_ride() { + let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let curves = Curves { + gain: Some(ride.clone()), + matrix: [ + Some(vec![ + seg(0, 1_000, 0.9, 0.9, Shape::Step), + seg(1_000, 9_000, 0.3, 0.3, Shape::Step), + ]), + None, + None, + None, + ], + ..Curves::default() + }; + let mut send = Send::new(&curves, OPEN); + let by_block = ramps_by_block(&mut send, 32); + let step = by_block[1_000 / QUANTUM][0]; + assert_eq!(step.length, 960, "the step jumps over its own length"); + for block in 1_024 / QUANTUM..1_920 / QUANTUM { + assert_eq!(by_block[block][0], step, "block {block}: the jump runs"); + } + let grid = by_block[1_920 / QUANTUM][0]; + let target = open( + RouteTransform { ll: 0.3, ..OPEN }, + law(automation::value_at(&ride, 1_920 + 63), 0.0), + ); + assert!( + aims(&grid, target, 64, 0), + "the grid moves the column again" + ); + } + + /// A timeline that seeks at render sample `at` (a block's first sample) to `to`: identity + /// before, `to + (s - at)` from there. + struct Seeking { + at: u64, + to: i64, + } + + impl GraphPreparedSourceSetDriver for Seeking { + 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 { + let at = i128::from(self.at); + let start = if first < at { + first + } else { + i128::from(self.to) + (first - at) + }; + let step = (first == at).then_some(crate::TimelineStep { + offset: 0, + timeline: self.to, + }); + Some(TimelineSpan { + start: i64::try_from(if step.is_some() { first } else { start }).ok()?, + step, + }) + } + } + + /// U2 fold 1 (reviewer's missing test): a seek landing inside a column jump. The send's own + /// mute closes at 1,000 over the mute length (to 1,200) and holds the riding gain; a seek at + /// render sample 1,088 back to timeline 256 (open) lands every curve there exactly, and the + /// landing ends the hold: from the next block the gain's grid moves the columns again. + /// + /// Red if a cursor's deferral or the output's column hold outlives a seek landing (the grid + /// would stay held until timeline 1,200). + #[test] + fn a_seek_landing_inside_a_column_jump_ends_the_hold() { + let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let curves = Curves { + gain: Some(ride.clone()), + mute: Some(step(1_000, 0.0, 1.0)), + ..Curves::default() + }; + let mut send = Send::new(&curves, OPEN); + let driver = Seeking { at: 1_088, to: 256 }; + let mut ramps = Vec::new(); + for index in 0..20_u64 { + let (mut left, mut right) = input(QUANTUM); + let time = GraphBlockTime::new( + Some(index * QUANTUM as u64), + QUANTUM as u32, + Some(LENGTHS), + Some(&driver), + ); + if send + .route + .begin_block(send.gate.snapshot(), &time) + .is_some() + { + send.route.mix(&mut left, &mut right); + } + ramps.push(send.route.ramps.ramps); + } + let curve = |t: i64| automation::value_at(&ride, t); + let landed = ramps[1_088 / QUANTUM]; + for column in 0..2 { + assert!( + aims(&landed[column], open(OPEN, law(curve(256), 0.0)), 0, column), + "the landing, column {column}: {:?} over {}", + landed[column].target, + landed[column].length + ); + } + // Block 18 reads timeline 320: its grid point retargets toward the curve at 383. + let grid = ramps[1_088 / QUANTUM + 1]; + for column in 0..2 { + assert!( + aims(&grid[column], open(OPEN, law(curve(383), 0.0)), 64, column), + "the grid after the landing, column {column}" + ); + } + } + + /// U2 fold 1 (reviewer's missing test): a hand mute of the send (a gate-only record over 200 + /// at 960) and the source's left mute curve closing at 1,000 (a gate fade over the mute + /// length): the hand mute's fade runs whole (the curve's gate event finds the column already + /// aimed at silence and does not restart it), and while the curve stays closed a hand unmute + /// (at 1,920) leaves the left column shut and reopens only the right one. + /// + /// Red if a gate event of the curve restarts the hand mute's fade, or if the hand unmute + /// reopens the column the curve still closes. + #[test] + fn a_hand_mute_and_a_gate_fade_together() { + let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let curves = Curves { + source: [Some(step(1_000, 0.0, 1.0)), None], + gain: Some(ride), + ..Curves::default() + }; + let mut send = Send::new(&curves, OPEN); + let mut ramps = ramps_by_block(&mut send, 15); + let muted = RouteFollowFlags { + mute: true, + ..send.route.flags + }; + send.revision += 1; + let record = FollowRouteControlRecord::new(OPEN, muted, 200).expect("record"); + send.producer + .write(send.pin.stamp(send.revision).expect("stamp"), record); + send.pin.publish(&send.gate, send.revision); + ramps.extend((15..30).map(|index| { + send.run(index * QUANTUM as u64); + send.route.ramps.ramps + })); + let fade = ramps[15]; + for column in 0..2 { + assert!( + aims(&fade[column], [0.0; 4], 200, column), + "column {column}" + ); + } + for block in 16..19 { + assert_eq!( + ramps[block], fade, + "block {block}: the hand fade runs whole" + ); + } + let unmuted = RouteFollowFlags { + mute: false, + ..send.route.flags + }; + send.revision += 1; + let record = FollowRouteControlRecord::new(OPEN, unmuted, 200).expect("record"); + send.producer + .write(send.pin.stamp(send.revision).expect("stamp"), record); + send.pin.publish(&send.gate, send.revision); + send.run(30 * QUANTUM as u64); + let reopened = send.route.ramps.ramps; + assert!( + super::COLUMNS[0] + .iter() + .all(|&index| reopened[0].target[index].to_bits() == 0.0_f32.to_bits()), + "the curve keeps the left column shut" + ); + assert_eq!(reopened[1].length, 200, "the right column reopens"); + assert!( + super::COLUMNS[1] + .iter() + .all(|&index| reopened[1].target[index] != 0.0), + "the right column reopens to the ride" + ); + } + + /// U2 fold 1 (reviewer's missing test): a source mute curve added at a swap (1,280) to a send + /// whose hand offset move (+3 dB over 2,000 from 1,152) is in flight, the curve closed there. + /// The added gate is a gate change: the left column fades to silence at once over the mute + /// length (R19-3), and the right column, which the curve does not reach, keeps the move. + /// + /// Red if the added gate waits for the move's end, or if it cuts the move on the other column. + #[test] + fn a_source_mute_added_while_a_move_is_in_flight_fades_its_column_at_once() { + let before = Curves { + gain: Some(flat(-6.0)), + ..Curves::default() + }; + let after = Curves { + source: [Some(flat(1.0)), None], + gain: Some(flat(-6.0)), + ..Curves::default() + }; + let moved = RouteTransform { gain: 3.0, ..OPEN }; + let mut predecessor = Send::new(&before, OPEN); + for index in 0..18_u64 { + predecessor.run(index * 64); + } + predecessor.write(moved, 2_000); + for index in 18..20_u64 { + predecessor.run(index * 64); + } + let record = predecessor.route.ramps.ramps; + let mut successor = + Send::continuing(&after, moved, [true, false, false, false, false, false]); + swap(&mut successor, &mut predecessor); + successor.run(20 * 64); + let fade = successor.route.ramps.ramps[0]; + assert!( + aims(&fade, [0.0; 4], 200, 0), + "{:?} over {}", + fade.target, + fade.length + ); + assert_eq!(successor.route.ramps.positions[0], 64); + assert_eq!( + successor.route.ramps.ramps[1], record[1], + "the move runs on" + ); + } } diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 0a975ead3..a7a1268b6 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -3008,7 +3008,8 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( }) .map_err(|error| match error { graph::GraphRouteControlError::AlreadyAttached - | graph::GraphRouteControlError::BoundRoute => { + | graph::GraphRouteControlError::BoundRoute + | graph::GraphRouteControlError::CursorTable(_) => { graph_failure("host.route_control.attach") } }) diff --git a/crates/host-core/tests/route_mute.rs b/crates/host-core/tests/route_mute.rs index 34e687f74..0b28c1d73 100644 --- a/crates/host-core/tests/route_mute.rs +++ b/crates/host-core/tests/route_mute.rs @@ -2778,3 +2778,66 @@ fn a_running_send_ride_across_an_unrelated_rebuild_is_the_never_swapped_twin() { ); } } + +/// #1654 U2 fold 1, MAJOR-3 (every bank width and the scalar oracle). The send `t-b` keeps a flat +/// `gain_db` ride at -6 dB (its table continues) and a rebuild at block 6 removes either its flat +/// `rr` entry at 0.5 or its source strip's mute entry (held muted): the send stays a following +/// route, no join record moves its cell, and no cursor of the successor hears the removed curve. +/// The carry lands the send on the successor's static value over the removed row's own length +/// (`SendMatrix`, the pan length, for `rr`; the mute length for the mute curve): from the first +/// block boundary after that landing, the send's plane is the successor prepared fresh, bit for +/// bit, and render allocates nothing. +/// +/// Test value: red if a removed row or source mute curve of a send that stays seven-word leaves +/// the send on the removed curve's last value. +#[cfg(feature = "test-support")] +#[test] +fn removing_a_send_row_or_its_source_mute_curve_lands_on_the_fresh_plan() { + use follow::*; + use send_rows::{entry, flat}; + use session::ParameterUnit; + use successor::{BLOCKS, QUANTUM, SWAP_BLOCK}; + let pcm = successor::Feed::new("t", 29); + let mut b = model(QUANTUM as u32, SendTap::PreFader, false, true); + b.automation = vec![entry("send-gain", 1, flat(-6.0, ParameterUnit::Db))]; + let pan = controls::LiveRamps::for_session(&b).pan_samples as usize; + let mut with_rr = b.clone(); + with_rr + .automation + .push(entry("send-rr", 6, flat(0.5, ParameterUnit::Linear))); + let mut with_mute = b.clone(); + with_mute.automation.push(mute_entry(1.0, &[])); + let quiet = |_: usize, + _: &mut host_core::HostLiveControlHandles, + _: &mut engine::realtime::PlanRevisionGate| {}; + for backend in follow::backends() { + let fresh = + successor::reference_run(&successor::Session::compile(b.clone()), &[&pcm], backend); + let fresh = plane(&fresh, 1); + for (name, a, length) in [("rr", &with_rr, pan), ("mute", &with_mute, M as usize)] { + let what = format!("{backend:?} {name}"); + let (swapped, handles) = live_swap(a, Some((&b, a)), backend, &pcm, quiet); + assert_eq!( + handles + .expect("the successor's handles") + .follow_route_controls + .len(), + 1, + "{what}: the send stays a following route" + ); + assert_quiet(&swapped, &what); + let send = plane(&swapped, 1); + let at = SWAP_BLOCK * QUANTUM; + assert!( + first_difference_from(&send[..at], &fresh[..at], 0).is_some(), + "{what}: not vacuous, the removed curve is heard before the swap" + ); + let landed = next_boundary(at + length, QUANTUM); + assert_bits_equal( + &send[landed..BLOCKS * QUANTUM], + &fresh[landed..BLOCKS * QUANTUM], + &format!("{what}: the fresh plan after the landing"), + ); + } + } +} diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 7747ffa77..934fdba8f 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -6947,3 +6947,59 @@ fn send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts() { exact(&mut browser, &c_abi, label); } } + +/// #1654 U2 fold 1, MAJOR-3 on both hosts. `t1-b` keeps a flat `gain_db` ride at -6 dB (its table +/// continues) and a rebuild before block 4 removes either its flat `rr` entry at 0.5 or `t1`'s +/// mute entry (held muted): the send stays a following route, no join record moves its cell and no +/// cursor hears the removed curve. Each rebuild completes `EXACT` on both hosts, and from the +/// first block boundary after the landing over the removed row's own length (`SendMatrix`, 240, +/// for `rr`; the mute length, 480, for the mute curve) both hosts render a fresh plan of the +/// committed model, bit for bit. +/// +/// Test value: red if a removed row or source mute curve of a send that stays seven-word leaves +/// the send on the removed curve's last value on either host, or if the hosts disagree. +#[cfg(feature = "test-support")] +#[test] +fn removing_a_send_row_or_its_source_mute_curve_lands_alike_on_both_hosts() { + use session::{AutomationShape, ParameterUnit}; + const RUN: u64 = 24; + const EDIT: u64 = 4; + let end = u64::from(QUANTUM) * BLOCKS; + let flat = |value: f32, shape, unit| vec![send_piece(shape, (0, value), (end, value), unit)]; + let mut kept = follow_model(false, false); + kept.automation.push(send_entry( + "t1-b-gain", + 1, + flat(-6.0, AutomationShape::Linear, ParameterUnit::Db), + )); + let mut with_rr = kept.clone(); + with_rr.automation.push(send_entry( + "t1-b-rr", + 6, + flat(0.5, AutomationShape::Linear, ParameterUnit::Linear), + )); + let mut with_mute = follow_model(true, false); + with_mute.automation[0].segments = flat(1.0, AutomationShape::Step, ParameterUnit::Linear); + with_mute.automation.extend(kept.automation.iter().cloned()); + let fresh = both_run(&kept, RUN, &[]); + for (label, model, removed, length) in [ + ("rr", &with_rr, "t1-b-rr", 240_u64), + ("the source mute", &with_mute, "t1-mute", 480), + ] { + let edits = vec![SessionEdit::RemoveAutomation { + automation_id: id(removed), + }]; + let run = both_run(model, RUN, &[(EDIT, edits, EditPath::Rebuild)]); + assert_ne!( + run[..EDIT as usize], + fresh[..EDIT as usize], + "{label}: not vacuous, the removed curve is heard" + ); + let landed = (EDIT * u64::from(QUANTUM) + length).div_ceil(u64::from(QUANTUM)) as usize; + assert_eq!( + run[landed..], + fresh[landed..], + "{label}: the committed model's bits after the landing" + ); + } +} From fbb252e8a1c119d3a10c53f28dbc3fc7bced4b37 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 15:45:59 +0000 Subject: [PATCH 20/36] Hold a route column's owed landing per column (K1 + #1654 U2 fold 2) The owed landing after a removed row moves from a cursor to the column (FollowCursors::owed): the walks are cut at its offset, the column retargets to its heard composition over the owed length, and its grid holds; a column that loses its last curve with a move in flight owes the landing at the ramp's free sample instead of retargeting at once. The owed length no longer leaks to later jumps. Gates on the graph, host-core (Simd8, Simd4, scalar) and the C ABI, red before. Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 45 ++ crates/capi/src/runtime/live_tests.rs | 78 +++ crates/graph/src/runtime.rs | 502 ++++++++++++++---- crates/host-core/tests/route_mute.rs | 108 ++++ 4 files changed, 640 insertions(+), 93 deletions(-) diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index b940a48d8..f0ad5c7f7 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -806,3 +806,48 @@ owed slots), `GraphFollowRouteControlBinding` 184 -> 856 (it carries the column to bind); `GraphFollowRouteSource` 120 and `ComposeCursor` 104 unchanged. Charges read `size_of`, so no pinned number moved. Realtime policy: 171 marked regions in 35 files (unchanged). `scripts/check-graph-policy.sh`: PASS. + +## Decision record: U2 fold 2 (batch 19) + +Fold round 2 of the U2 fold-1 FAIL (MAJOR-A, MAJOR-B, one NIT). This is the exact path; the R19-5 +fallback flag is not used. Files: `crates/graph/src/runtime.rs` and the gates in +`crates/host-core/tests/route_mute.rs` and `crates/capi/src/runtime/live_tests.rs`. Nothing changed +under `crates/engine/src/realtime/`, in `crates/automation` or in any row class or table, so the SDK +was not regenerated. + +- **The fix: an owed landing belongs to a column.** `FollowCursors::owed: [Option<(free, length)>; 2]` + replaces `RouteColumn::owed`, `owe_landing` and `apply_owed`. No cursor carries an owed length + now. A new render helper, `walk_follow`, wraps `walk_columns`. When a column owes a landing at + offset `o` of the block, the helper cuts the walks at `o` and `o + 1`. The walks run over windows + of the column span (`column_window`, the same rule as `NodeSpan::window`), so the cuts move no + event. At `o` the column lands on its composition over the owed length: the heard values, or the + cell's words when the column has no curve. A coincident event of the column itself takes the + longer ramp (R19-4). A column that has a walk also calls `Composed::retarget` between its cut + walks, so its value grid holds inside the landing (R19-3). The landing is spent by an event of + the column before `o`, or by a record (both compose the column). A seek at or before `o` drops + the landing. The begin-block probe walks a copy of the owed landings and commits that copy only + when the block is inactive. +- **MAJOR-A.** In `place_carried`'s branch for a column with no walk: when the column lost a row + and a column ramp is in flight, the landing is owed at the ramp's first free sample. The column + does not retarget at once. A settled column still lands at once. +- **NIT.** The fold-1 MAJOR-1 change to K1's `merge` (held events are heard) touches the walk that + #1655 V2 also uses. The #1655 merge must take it as it is. + +Gates (red before the fix, green after): +- MAJOR-A, graph: `a_column_losing_its_last_curve_keeps_a_move_in_flight`. Red: at block 20 the + 2,000-sample move becomes a 960 ramp. +- MAJOR-A, host-core: `route_mute.rs` `a_send_column_losing_its_last_curve_keeps_the_hand_move_in_flight` + (Simd8 when avx2, Simd4, scalar; both planes match the twin until the move ends, then match the + fresh plan). Red: Simd8 plane 0, sample 768. +- MAJOR-A, C ABI: `a_send_column_losing_its_last_curve_keeps_the_hand_move_on_the_c_abi` (the + watermark has no transition fallback, so EXACT). Red: plane 0, the move is cut. +- MAJOR-B P2, graph: `an_owed_landing_lengthens_no_later_jump`. Red: block 54 lands over 960 + instead of the twin's 480. +- MAJOR-B P3, graph: `a_source_mute_close_before_an_owed_landing_fades_over_the_mute_length`. + Red: the close fades over 960. +- The fold-1 MAJOR-3 gates stay green. + +Sizes (x86-64, measured): `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 1,000 -> 1,072 (the cut steps and seek in +`FollowBlock`), `GraphFollowRouteControlBinding` 856 -> 808 (no owed slot per column table). Charges +read `size_of`, so no pinned number moved. Realtime policy: 171 marked regions in 35 files (unchanged). +`scripts/check-graph-policy.sh`: PASS. diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 27979dfd4..e75181fee 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -12139,6 +12139,84 @@ fn removing_a_send_row_or_its_source_mute_curve_lands_on_the_fresh_plan_on_the_c } } +/// #1654 U2 fold 2, MAJOR-A on the C ABI. The send `eq0-b` hears flat `ll` and `rr` entries at +/// 0.5; a hand gain move to -6 dB over 1,000 samples is in flight when a rebuild removes the +/// `ll` entry, so the send's left source column no longer hears a curve. The rebuild completes +/// `EXACT`, with no transition fallback; until the move's end every sample is a twin's that took +/// the same move and no rebuild (the move keeps its length on both columns); after the column's +/// landing over `SendMatrix` (240) the plan renders a fresh plan of the committed document. +/// +/// Test value: red if a column that loses its last curve retargets at the swap and cuts the +/// live move in flight. +#[test] +fn a_send_column_losing_its_last_curve_keeps_the_hand_move_on_the_c_abi() { + use session::ParameterUnit::Linear; + const RAMP: u32 = 1_000; + let document = send_rows_document( + vec![ + route_entry("send-ll", "eq0-b", 3, send_flat(0.5, Linear, false)), + route_entry("send-rr", "eq0-b", 6, send_flat(0.5, Linear, false)), + ], + |_| {}, + ); + let quantum = 128_usize; + let edit = send_row_edit("gain_db", -6.0, Some(RAMP)); + let mut rig = Rig::new(&document); + let mut twin = Rig::new(&document); + for _ in 0..4 { + assert_eq!(bits(&rig.step()), bits(&twin.step())); + } + let (start, _) = live_and_render(&mut rig, std::slice::from_ref(&edit), 2); + let _ = live_and_render(&mut twin, std::slice::from_ref(&edit), 2); + let (adoption, live) = rebuild_and_adopt( + &mut rig, + &[SessionEdit::RemoveAutomation { + automation_id: StableId::parse("send-ll").expect("automation ID"), + }], + 48, + ); + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + assert_eq!( + watermark.transition_fallback_count, 0, + "no transition fallback" + ); + while (twin.block as usize) * quantum < adoption as usize { + twin.step(); + } + let reference: Vec<_> = (0..live.len()).map(|_| twin.step()).collect(); + let end = (start + u64::from(RAMP) - adoption) as usize; + assert!(end > 0, "the move is in flight at the adoption"); + for plane in 0..2 { + let (live, twin) = ( + plane_bits(&live, plane, quantum), + plane_bits(&reference, plane, quantum), + ); + assert_eq!( + live[..end], + twin[..end], + "plane {plane}: the move runs to its end" + ); + } + let fresh = seeked_reference(&rig.snapshot(), adoption, 48); + let landed = (end + 240).div_ceil(quantum) * quantum; + let mut lands = false; + for plane in 0..2 { + let (send, fresh, twin) = ( + plane_bits(&live, plane, quantum), + plane_bits(&fresh, plane, quantum), + plane_bits(&reference, plane, quantum), + ); + lands |= send[end..landed] != twin[end..landed]; + assert_eq!( + send[landed..], + fresh[landed..], + "plane {plane}: a fresh plan after the landing" + ); + } + assert!(lands, "the column lands after the move"); +} + /// #1654 R19-5 on the C ABI. On [`send_ride_document`], a live send move with its own 4,000 /// samples (the hand gain offset `+3`, or a D6 `matrix_lr` edit) is in flight when a rebuild /// changes the gain ride's table (the carried cursor is repositioned). The rebuild completes diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index 5daaa3f6e..094a60081 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -1580,10 +1580,6 @@ struct RouteColumn { probe: automation::Composed, roles: ColumnRoles, placement: [Placement; ROLES], - /// A landing a carry owes the column (U2 fold 1, MAJOR-3): the cursor whose deferred jump - /// carries it and the jump length it lands over (a removed row's own length), until that - /// deferral fires. - owed: Option<(usize, u32)>, } impl RouteColumn { @@ -1613,7 +1609,6 @@ impl RouteColumn { probe: table()?, roles, placement: [Placement::Continued; ROLES], - owed: None, })) } } @@ -1654,6 +1649,12 @@ struct FollowCursors { /// Per column, the roles the predecessor's column heard and this plan's does not (a removed /// row or source mute curve), set by a carry and read by its first block (U2 fold 1, MAJOR-3). dropped: [[bool; ROLES]; 2], + /// Per column, a landing a carry owes it (U2 fold 2): at node time `free`, the first free + /// sample of the column ramp in flight at the swap, the column retargets to its composition + /// over `length` (a removed row's own length). It is the column's, never a cursor's; any + /// event or record that composes the column first spends it, and a seek reaching the block + /// at or before `free` drops it (its landing composes). + owed: [Option<(i64, u32)>; 2], /// How the route's next block begins: fresh, carried or running. start: FollowStart, } @@ -1678,6 +1679,11 @@ enum FollowStart { #[derive(Clone, Copy)] struct FollowBlock { spans: [Option; 2], + /// Each column span's step (`(offset, node time)`), to cut it at an owed landing. + steps: [Option<(u32, i64)>; 2], + /// The graph's seek step into the block, if any. + seek: Option<(u32, i64)>, + frames: u32, lengths: crate::GraphRouteJumpLengths, start: Option, } @@ -1685,6 +1691,9 @@ struct FollowBlock { impl FollowBlock { const IDLE: Self = Self { spans: [None; 2], + steps: [None; 2], + seek: None, + frames: 0, lengths: crate::GraphRouteJumpLengths { mute: 0, gain: 0, @@ -1734,6 +1743,7 @@ impl LiveFollowRoute { gain_law: source.gain_law, continued: source.continued, dropped: [[false; ROLES]; 2], + owed: [None; 2], start: FollowStart::Fresh, }; let composition = Composition { @@ -1924,12 +1934,10 @@ impl FollowCursors { cursor.set_jump(lengths.for_row(ColumnRoles::row(role))); } } - column.apply_owed(lengths); } let idle = FollowBlock { - spans: [None; 2], lengths, - start: None, + ..FollowBlock::IDLE }; let Some(span) = time.node_span(self.arrival) else { return (idle, start); @@ -1944,23 +1952,25 @@ impl FollowCursors { return (idle, start); } let program = &self.program; - let spans = [0, 1].map(|column| { + let seek = span.step.map(|step| (step.offset, step.timeline)); + let steps = [0, 1].map(|column| { let walk = self.columns.get(column).and_then(Option::as_ref)?; - let step = span - .step - .map(|step| (step.offset, step.timeline)) - .or_else(|| { - if start != FollowStart::Fresh { - return None; - } - let differs = walk.walk.cursors().iter().any(|cursor| { - program.cell(cursor.cell() as usize).is_some_and(|cell| { - let value = automation::value_at(cell.segments(), span.start); - cursor.state().target().map(f32::to_bits) != Some(value.to_bits()) - }) - }); - differs.then_some((0, span.start)) + seek.or_else(|| { + if start != FollowStart::Fresh { + return None; + } + let differs = walk.walk.cursors().iter().any(|cursor| { + program.cell(cursor.cell() as usize).is_some_and(|cell| { + let value = automation::value_at(cell.segments(), span.start); + cursor.state().target().map(f32::to_bits) != Some(value.to_bits()) + }) }); + differs.then_some((0, span.start)) + }) + }); + let spans = [0, 1].map(|column| { + self.columns.get(column).and_then(Option::as_ref)?; + let step = steps.get(column).copied().flatten(); // The graph's step lies inside the block, so the span is valid; a refused one leaves // the column holding its curves, with no panic. automation::NodeSpan::new(span.start, step, frames).ok() @@ -1968,6 +1978,9 @@ impl FollowCursors { ( FollowBlock { spans, + steps, + seek, + frames, lengths, start: Some(span.start), }, @@ -2012,6 +2025,192 @@ fn walk_columns( } } +/// The offsets `[from, to)` of a column span (offset 0 at node time `start`, seek `step`, +/// `frames` long) as a span of their own, as the automation crate's span window: the same node +/// times, and the step when it lies inside. `None` for an empty or out-of-block window. +fn column_window( + start: i64, + step: Option<(u32, i64)>, + frames: u32, + from: u32, + to: u32, +) -> Option { + if from >= to || to > frames { + return None; + } + let (start, step) = match step { + Some((step, time)) if from > step => (time.checked_add(i64::from(from - step))?, None), + Some((step, time)) if step < to => ( + start.checked_add(i64::from(from))?, + Some((step - from, time)), + ), + _ => (start.checked_add(i64::from(from))?, None), + }; + automation::NodeSpan::new(start, step, to - from).ok() +} + +/// [`walk_columns`] with the columns' owed landings (U2 fold 2): a column that owes a landing +/// at offset `o` of this block has its walks cut at `o` and `o + 1` (a cell's events over a +/// window are its block's events there, so the cuts move no event), and at `o` it lands on its +/// composition (the heard values; its cell's words with no curve) over the owed length, an event +/// passed to `change` as any other. A coincident event of its own takes the longer ramp (R19-4). +/// A column with a walk takes the landing as a column jump ([`automation::Composed::retarget`] +/// between its cut walks), so its value grid holds inside it (R19-3). An event of the column +/// before `o` composed it and spends the landing; a seek at or before `o` drops it (its landing +/// composes). A landing that fired or was spent leaves `owed` as `None`. +fn walk_follow( + program: &automation::Program, + mut columns: [Option<(&mut automation::Composed, ColumnRoles)>; 2], + block: &FollowBlock, + owed: &mut [Option<(i64, u32)>; 2], + composition: Composition<'_>, + mut change: impl FnMut(usize, automation::ComposedEvent), +) { + let frames = block.frames; + let Some(start) = block.start else { + walk_columns(program, columns, block.spans, composition, change); + return; + }; + // Each owed column's landing offset, when it lands in this block. + let mut pending = [None::; 2]; + for (slot, owed) in pending.iter_mut().zip(owed.iter_mut()) { + let Some((free, _)) = *owed else { + continue; + }; + let offset = u32::try_from(free.saturating_sub(start).max(0)).unwrap_or(u32::MAX); + if offset >= frames { + continue; + } + if block.seek.is_some_and(|(seek, _)| seek <= offset) { + *owed = None; + continue; + } + *slot = Some(offset); + } + let [first, second] = pending; + if first.is_none() && second.is_none() { + walk_columns(program, columns, block.spans, composition, change); + return; + } + let cut = |offset: Option, after: u32| { + offset.map_or(frames, |offset| { + core::cmp::min(offset.saturating_add(after), frames) + }) + }; + let mut cuts = [ + 0, + cut(first, 0), + cut(first, 1), + cut(second, 0), + cut(second, 1), + frames, + ]; + cuts.sort_unstable(); + for (&from, &to) in cuts.iter().zip(cuts.iter().skip(1)) { + if from >= to { + continue; + } + let spans = [0, 1].map(|column| { + block.spans.get(column).copied().flatten()?; + let step = block.steps.get(column).copied().flatten(); + column_window(start, step, frames, from, to) + }); + // Per landing column: whether its own event at `o` took the landing, and the target of + // one whose ramp the landing lengthened. + let mut fired = [false; 2]; + let mut lengthened = [None::; 2]; + { + let [zero, one] = &mut columns; + walk_columns( + program, + [ + zero.as_mut().map(|(walk, roles)| (&mut **walk, *roles)), + one.as_mut().map(|(walk, roles)| (&mut **walk, *roles)), + ], + spans, + composition, + |column, mut event| { + event.offset = event.offset.saturating_add(from); + if let (Some(Some(offset)), false) = (pending.get(column).copied(), event.held) + { + let length = owed.get(column).copied().flatten().map_or(0, |(_, n)| n); + if event.offset < offset { + // The column composed before its landing: the debt is spent. + if let Some(slot) = pending.get_mut(column) { + *slot = None; + } + if let Some(slot) = owed.get_mut(column) { + *slot = None; + } + } else if event.offset == offset { + if let Some(slot) = fired.get_mut(column) { + *slot = true; + } + if length > event.ramp { + event.ramp = length; + event.kind = automation::EventKind::Jump; + if let Some(slot) = lengthened.get_mut(column) { + *slot = Some(event.value); + } + } + } + } + change(column, event); + }, + ); + } + for column in 0..COLUMNS.len() { + if pending.get(column).copied().flatten() != Some(from) { + continue; + } + let length = owed.get(column).copied().flatten().map_or(0, |(_, n)| n); + let time = start.saturating_add(i64::from(from)); + let walk = columns.get_mut(column).and_then(Option::as_mut); + if fired.get(column).copied().unwrap_or(false) { + if let (Some((walk, _)), Some(Some(target))) = + (walk, lengthened.get(column).copied()) + { + walk.retarget(target, time, length); + } + } else { + let landing = |value| automation::ComposedEvent { + offset: from, + value, + ramp: length, + kind: automation::EventKind::Jump, + held: false, + }; + match walk { + Some((walk, roles)) => { + let roles = *roles; + let target = walk.compose_heard(|heard| { + column_target(column, roles, composition, |index| heard.value(index)) + }); + if !walk + .target() + .is_some_and(|held| automation::Composable::same_bits(&held, &target)) + { + walk.retarget(target, time, length); + change(column, landing(target)); + } + } + None => { + let target = + column_target(column, ColumnRoles::default(), composition, |_| 0.0); + change(column, landing(target)); + } + } + } + if let Some(slot) = pending.get_mut(column) { + *slot = None; + } + if let Some(slot) = owed.get_mut(column) { + *slot = None; + } + } + } +} + /// Column `index`'s next event, if it walks this block. fn next_column_event( index: usize, @@ -2063,62 +2262,6 @@ impl FollowCursors { } } -impl RouteColumn { - /// The owed landing's length on its cursor while its deferral is pending (the longer of the - /// cursor's own row length and the owed one, R19-4); once it has fired the debt is spent. - fn apply_owed(&mut self, lengths: crate::GraphRouteJumpLengths) { - let Some((index, length)) = self.owed else { - return; - }; - let roles = self.roles; - let own = (0..ROLES) - .find(|&role| roles.of(role) == Some(index)) - .map_or(0, |role| lengths.for_row(ColumnRoles::row(role))); - match self.walk.cursor_mut(index) { - Some(cursor) if cursor.state().deferred_jump().is_some() => { - cursor.set_jump(core::cmp::max(own, length)); - } - _ => self.owed = None, - } - } - - /// Owe the column a landing at node time `free` over `length` (U2 fold 1, MAJOR-3): the - /// composition moved with no cursor whose curve moved (a removed row or source mute curve). - /// The first value cursor (else the first gate) is placed at `at` with no target and defers - /// its jump to `free`, so its jump there emits whatever its curve, and the column composes - /// and lands; until then its grid is held (R19-3). - fn owe_landing( - &mut self, - program: &automation::Program, - lengths: crate::GraphRouteJumpLengths, - at: i64, - free: i64, - length: u32, - ) { - let kinds = [automation::CursorKind::Value, automation::CursorKind::Gate]; - let Some(index) = kinds.iter().find_map(|&kind| { - self.walk - .cursors() - .iter() - .position(|cursor| cursor.kind() == kind) - }) else { - return; - }; - let Some(cursor) = self.walk.cursor_mut(index) else { - return; - }; - let Some(cell) = program.cell(cursor.cell() as usize) else { - return; - }; - let mut state = automation::CellState::new(); - state.reposition(&cell, at); - state.defer_jump(free); - *cursor.state_mut() = state; - self.owed = Some((index, length)); - self.apply_owed(lengths); - } -} - fn for_placed_values(walk: &mut RouteColumn, mut each: impl FnMut(&mut automation::ComposeCursor)) { for role in 0..ROLES { let placed = walk @@ -2195,6 +2338,8 @@ impl LiveFollowRoute { } } } + // The record composed every column: an owed landing is spent (U2 fold 2). + follow.owed = [None; 2]; *mute = follow.silent[0] && follow.silent[1]; applied = 1; } @@ -2215,6 +2360,7 @@ impl LiveFollowRoute { self.ramps = ramps; self.mute = mute; self.follow.silent = from.map_or([mute; 2], |from| from.follow.silent); + self.follow.owed = [None; 2]; let continued = self.follow.continued; for (column, (walk, dropped)) in self .follow @@ -2237,7 +2383,6 @@ impl LiveFollowRoute { let Some(walk) = walk else { continue; }; - walk.owed = None; for role in 0..ROLES { let Some(index) = walk.roles.of(role) else { continue; @@ -2290,9 +2435,11 @@ impl LiveFollowRoute { /// curve of its predecessor and composes otherwise (U2 fold 1, MAJOR-3: no cursor's curve /// carries that move) lands over the removed row's own length (the mute length for a mute /// curve, the longer one with a coincident moved value, R19-4): at once when settled, a column - /// jump; else at the column ramp's first free sample, a landing owed on one of its cursors - /// ([`RouteColumn::owe_landing`]). A column with no curve compares its cell's target over the - /// mute length (a removed row's length when it lost one), as before. + /// jump; else at the column ramp's first free sample, a landing the column owes + /// (`FollowCursors::owed`, U2 fold 2: the column's, never a cursor's; [`walk_follow`] lands + /// it). A column with no curve compares its cell's target over the mute length, at once; one + /// that lost a row lands over that row's length, at once when settled, else owed at the + /// column ramp's first free sample (U2 fold 2, MAJOR-A). fn place_carried(&mut self, at: i64) { let Self { ramps, @@ -2326,9 +2473,19 @@ impl LiveFollowRoute { .dropped .get(column) .is_some_and(|dropped| dropped.iter().any(|&role| role)); + let remaining = ramps.remaining(column); + let free = at.saturating_add(i64::from(remaining)); let Some(walk) = follow.columns.get_mut(column).and_then(Option::as_mut) else { let target = column_target(column, ColumnRoles::default(), composition, |_| 0.0); let length = if has_dropped { dropped } else { jump }; + if has_dropped && remaining > 0 && !same_entries(adopted, target.0) { + // U2 fold 2, MAJOR-A: the move in flight keeps its length; the column's + // landing is owed at its end. + if let Some(slot) = follow.owed.get_mut(column) { + *slot = Some((free, length)); + } + continue; + } ramps.retarget_column( column, with_column(ramps.target(), column, target.0), @@ -2381,8 +2538,6 @@ impl LiveFollowRoute { let target = walk.walk.compose_heard(|heard| { column_target(column, roles, composition, |index| heard.value(index)) }); - let remaining = ramps.remaining(column); - let free = at.saturating_add(i64::from(remaining)); if same_entries(adopted, target.0) { for_placed_values(walk, |cursor| { let heard = cursor.heard(); @@ -2432,10 +2587,12 @@ impl LiveFollowRoute { let silent = follow.silent.get(column).copied().unwrap_or(false); walk.walk.seed_target((adopted, silent)); for_placed_values(walk, |cursor| cursor.state_mut().defer_jump(free)); - if has_dropped || !any_placed { - // MAJOR-3 with a move in flight: the landing is owed at its end. - let length = if has_dropped { dropped } else { jump }; - walk.owe_landing(&follow.program, lengths, at, free, length); + if (has_dropped || !any_placed) + && let Some(slot) = follow.owed.get_mut(column) + { + // MAJOR-3 with a move in flight: the column's landing is owed at its end + // (U2 fold 2: held per column, not on a cursor). + *slot = Some((free, if has_dropped { dropped } else { jump })); } } } @@ -2490,6 +2647,7 @@ impl LiveFollowRoute { columns, silent: heard_silent, gain_law, + owed: follow_owed, .. } = follow; let composition = Composition { @@ -2503,14 +2661,16 @@ impl LiveFollowRoute { let mut target = ramps.target().map(f32::to_bits); let mut silent = *heard_silent; let mut from = None; + let mut owed = *follow_owed; let [zero, one] = columns; - walk_columns( + walk_follow( program, [ zero.as_mut().map(|walk| (&mut walk.probe, walk.roles)), one.as_mut().map(|walk| (&mut walk.probe, walk.roles)), ], - block.spans, + block, + &mut owed, composition, |column, event| { if event.held { @@ -2536,6 +2696,7 @@ impl LiveFollowRoute { let _copied = column.walk.copy_from(&column.probe); } *heard_silent = silent; + *follow_owed = owed; } from } @@ -2562,6 +2723,7 @@ impl LiveFollowRoute { columns, silent, gain_law, + owed, .. } = follow; let composition = Composition { @@ -2570,13 +2732,14 @@ impl LiveFollowRoute { law: *gain_law, }; let [zero, one] = columns; - walk_columns( + walk_follow( program, [ zero.as_mut().map(|walk| (&mut walk.walk, walk.roles)), one.as_mut().map(|walk| (&mut walk.walk, walk.roles)), ], - block.spans, + block, + owed, composition, |column, event| { if event.held { @@ -20841,4 +21004,157 @@ mod send_automation_tests { "the move runs on" ); } + + /// U2 fold 2, MAJOR-A (R19-5, a column that loses its last curve): `ll` and `rr` ride flat, + /// a hand gain move (2,000 samples from 1,152) is in flight when a successor that keeps only + /// `rr` adopts the route at 1,280. Column 0 no longer hears a curve: its 2,000-sample move + /// runs to 3,152, where the column lands on its cell's value over `SendMatrix` (960); + /// column 1's move runs on. + /// + /// Red if the column retargets at the swap over the removed row's length, cutting the move. + #[test] + fn a_column_losing_its_last_curve_keeps_a_move_in_flight() { + let base = RouteTransform { gain: 1.0, ..OPEN }; + let moved = RouteTransform { gain: 0.5, ..OPEN }; + let before = Curves { + matrix: [Some(flat(0.5)), None, None, Some(flat(0.5))], + ..Curves::default() + }; + let after = Curves { + matrix: [None, None, None, Some(flat(0.5))], + ..Curves::default() + }; + let mut predecessor = Send::new(&before, base); + for index in 0..18_u64 { + predecessor.run(index * 64); + } + predecessor.write(moved, 2_000); + for index in 18..20_u64 { + predecessor.run(index * 64); + } + let record = predecessor.route.ramps.ramps; + let mut successor = + Send::continuing(&after, moved, [false, false, false, false, false, true]); + swap(&mut successor, &mut predecessor); + for index in 20..49_u64 { + successor.run(index * 64); + assert_eq!( + successor.route.ramps.ramps, record, + "block {index}: the move runs" + ); + } + successor.run(49 * 64); + let ramp = successor.route.ramps.ramps[0]; + assert!( + aims(&ramp, open(OPEN, 0.5), 960, 0), + "{:?} over {}", + ramp.target, + ramp.length + ); + assert_eq!(successor.route.ramps.positions[0], 48); + assert_eq!( + successor.route.ramps.ramps[1], record[1], + "column 1 runs on" + ); + } + + /// U2 fold 2, MAJOR-B (an owed landing's length is the column's, once): the gain rides and + /// the route's mute curve closes at 3,160 and reopens at 3,260; `ll` is removed at 1,280 with + /// a +3 dB hand move (2,000 samples from 1,152) in flight. The owed landing at 3,152 (cut by + /// the close) is spent; from 3,456 every column ramp is the twin's that never heard `ll` (the + /// gain lands at 3,460 over `SendGain`, 480). + /// + /// Red if the owed length stays on a cursor and stretches its later jumps. + #[test] + fn an_owed_landing_lengthens_no_later_jump() { + let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let mute = vec![ + seg(0, 3_160, 0.0, 1.0, Shape::Step), + seg(3_160, 3_260, 1.0, 0.0, Shape::Step), + ]; + let before = Curves { + gain: Some(ride.clone()), + mute: Some(mute.clone()), + matrix: [Some(flat(0.5)), None, None, None], + ..Curves::default() + }; + let after = Curves { + gain: Some(ride), + mute: Some(mute), + ..Curves::default() + }; + let moved = RouteTransform { gain: 3.0, ..OPEN }; + let run = |removed: bool| { + let mut predecessor = Send::new(if removed { &before } else { &after }, OPEN); + for index in 0..18_u64 { + predecessor.run(index * 64); + } + predecessor.write(moved, 2_000); + for index in 18..20_u64 { + predecessor.run(index * 64); + } + let mut successor = + Send::continuing(&after, moved, [true, true, false, false, false, false]); + swap(&mut successor, &mut predecessor); + (20..64_u64) + .map(|index| { + successor.run(index * 64); + successor.route.ramps.ramps + }) + .collect::>() + }; + let removed = run(true); + let twin = run(false); + assert_eq!( + twin[54 - 20][0].length, + 480, + "the twin's gain lands over SendGain" + ); + for (block, (removed, twin)) in removed.iter().zip(&twin).enumerate().skip(54 - 20) { + assert_eq!(removed, twin, "block {}", block + 20); + } + } + + /// U2 fold 2, MAJOR-B (a gate is never the owed landing's carrier): column 0 hears a source + /// mute curve and `ll`; `ll` is removed at 1,280 with a hand move in flight, and the + /// successor's source mute closes at 2,400. The close fades over the mute length (200). + /// + /// Red if the owed `SendMatrix` length (960) is put on the gate cursor. + #[test] + fn a_source_mute_close_before_an_owed_landing_fades_over_the_mute_length() { + let base = RouteTransform { gain: 1.0, ..OPEN }; + let moved = RouteTransform { gain: 0.5, ..OPEN }; + let before = Curves { + source: [Some(flat(0.0)), None], + matrix: [Some(flat(0.5)), None, None, Some(flat(0.5))], + ..Curves::default() + }; + let after = Curves { + source: [Some(step(2_400, 0.0, 1.0)), None], + matrix: [None, None, None, Some(flat(0.5))], + ..Curves::default() + }; + let mut predecessor = Send::new(&before, base); + for index in 0..18_u64 { + predecessor.run(index * 64); + } + predecessor.write(moved, 2_000); + for index in 18..20_u64 { + predecessor.run(index * 64); + } + let mut successor = + Send::continuing(&after, moved, [false, false, false, false, false, true]); + swap(&mut successor, &mut predecessor); + for index in 20..38_u64 { + successor.run(index * 64); + } + let fade = successor.route.ramps.ramps[0]; + assert!( + aims(&fade, [0.0; 4], 200, 0), + "{:?} over {}", + fade.target, + fade.length + ); + assert_eq!(successor.route.ramps.positions[0], 2_432 - 2_400); + } } diff --git a/crates/host-core/tests/route_mute.rs b/crates/host-core/tests/route_mute.rs index 0b28c1d73..47932db60 100644 --- a/crates/host-core/tests/route_mute.rs +++ b/crates/host-core/tests/route_mute.rs @@ -2779,6 +2779,114 @@ fn a_running_send_ride_across_an_unrelated_rebuild_is_the_never_swapped_twin() { } } +/// #1654 U2 fold 2, MAJOR-A (every bank width and the scalar oracle). The send `t-b` hears flat +/// `ll` and `rr` entries at 0.5; at block 5 a hand move of its gain to -6 dB is written through +/// its seven-word producer, and a rebuild at block 6 removes the `ll` entry while the move is in +/// flight: the send's left source column no longer hears a curve. The move keeps its length on +/// both columns (both planes are the never-swapped twin's, bit for bit, until it ends), the left +/// column then lands on its static `ll` over `SendMatrix` (the pan length), and from the first +/// block boundary after that landing both planes are the successor prepared fresh, bit for bit. +/// Render allocates nothing. +/// +/// Test value: red if a column that loses its last curve retargets at the swap and cuts the +/// live move in flight. +#[cfg(feature = "test-support")] +#[test] +fn a_send_column_losing_its_last_curve_keeps_the_hand_move_in_flight() { + use follow::*; + use send_rows::{entry, flat}; + use session::ParameterUnit; + use successor::{BLOCKS, QUANTUM, SWAP_BLOCK}; + const WRITE_BLOCK: usize = 5; + let pcm = successor::Feed::new("t", 31); + let mut a = model(QUANTUM as u32, SendTap::PreFader, false, true); + a.automation = vec![ + entry("send-ll", 3, flat(0.5, ParameterUnit::Linear)), + entry("send-rr", 6, flat(0.5, ParameterUnit::Linear)), + ]; + let mut committed = a.clone(); + committed.routes[1].gain_db = -6.0; + assert_eq!(committed.routes[1].id.as_str(), "t-b"); + let mut b = committed.clone(); + b.automation.retain(|entry| entry.id.as_str() != "send-ll"); + // The record a commit of `committed` writes: the open transform at -6 dB (the automated + // entries' words are fallbacks render ignores) and the plan's automated set. + let length = 480_usize; + let transform = + graph_compiler::route_values(-6.0, [0.0, 0.0, 0.0, 1.0]).expect("in-domain send values"); + let pan = controls::LiveRamps::for_session(&b).pan_samples as usize; + let move_end = WRITE_BLOCK * QUANTUM + length; + assert!( + move_end > SWAP_BLOCK * QUANTUM, + "the move is in flight at the swap" + ); + let landed = next_boundary(move_end + pan, QUANTUM); + assert!(landed < BLOCKS * QUANTUM, "the landing ends inside the run"); + let hand = |block: usize, + handles: &mut host_core::HostLiveControlHandles, + gate: &mut engine::realtime::PlanRevisionGate| { + use builtins_compiler::TestOnlyWriteGate; + if block == WRITE_BLOCK { + let index = + host_core::follow_route_producer_position(&handles.follow_route_controls, "t-b") + .expect("t-b's seven-word producer"); + let automated = handles.follow_route_controls[index].automated(); + let [automated_gain, automated_mute, ll, lr, rl, rr] = automated.rows; + let flags = host_core::RouteFollowFlags { + mute: false, + follows_mute: true, + terms: [false; 2], + automated: automated.lanes, + automated_gain, + automated_mute, + automated_matrix: [ll, lr, rl, rr], + }; + let record = host_core::FollowRouteControlRecord::new(transform, flags, length as u32) + .expect("a send record"); + gate.write_published(|stamp| { + handles.follow_route_controls[index].write(stamp, record); + }); + } + }; + for backend in follow::backends() { + let what = format!("{backend:?}"); + let (twin, _) = live_swap(&a, None, backend, &pcm, hand); + let (swapped, handles) = live_swap(&a, Some((&b, &committed)), backend, &pcm, hand); + assert_eq!( + handles + .expect("the successor's handles") + .follow_route_controls + .len(), + 1, + "{what}: the send stays a following route" + ); + assert_quiet(&swapped, &what); + let fresh = + successor::reference_run(&successor::Session::compile(b.clone()), &[&pcm], backend); + for index in 0..2 { + let (send, twin, fresh) = ( + plane(&swapped, index), + plane(&twin, index), + plane(&fresh, index), + ); + assert_bits_equal( + &send[..move_end], + &twin[..move_end], + &format!("{what}: plane {index}, the move runs to its end"), + ); + assert_bits_equal( + &send[landed..BLOCKS * QUANTUM], + &fresh[landed..BLOCKS * QUANTUM], + &format!("{what}: plane {index}, the fresh plan after the landing"), + ); + } + assert!( + first_difference_from(&plane(&swapped, 0), &plane(&twin, 0), move_end).is_some(), + "{what}: not vacuous, the removed ll is heard after the move" + ); + } +} + /// #1654 U2 fold 1, MAJOR-3 (every bank width and the scalar oracle). The send `t-b` keeps a flat /// `gain_db` ride at -6 dB (its table continues) and a rebuild at block 6 removes either its flat /// `rr` entry at 0.5 or its source strip's mute entry (held muted): the send stays a following From ee936dc47d22735b00ca3ca5df78043f92a9850f Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 15:49:16 +0000 Subject: [PATCH 21/36] Drop a private intra-doc link from route_gain_law's doc (Documentation job) Co-Authored-By: Claude Opus 5.5 --- crates/graph-compiler/src/ids.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/graph-compiler/src/ids.rs b/crates/graph-compiler/src/ids.rs index f6105c90e..b1cd7bbd0 100644 --- a/crates/graph-compiler/src/ids.rs +++ b/crates/graph-compiler/src/ids.rs @@ -317,7 +317,7 @@ pub(crate) fn route_transform(gain_db: f32, matrix: [f32; 4]) -> Option Date: Sat, 17 Oct 2026 15:51:06 +0000 Subject: [PATCH 22/36] Drop the duplicate held-grid test the 19a port brought into 19b's merge Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/compose/tests.rs | 61 -------------------------- 1 file changed, 61 deletions(-) diff --git a/crates/automation/src/compose/tests.rs b/crates/automation/src/compose/tests.rs index 7b602d0e2..e17436cc1 100644 --- a/crates/automation/src/compose/tests.rs +++ b/crates/automation/src/compose/tests.rs @@ -627,64 +627,3 @@ fn an_unchanged_value_jump_keeps_the_gate_deferral() { [(1_000, EventKind::Jump, 480), (1_480, EventKind::Jump, 256)] ); } - -fn summed(heard: Heard<'_>) -> f32 { - heard.value(0) + heard.value(1) -} - -/// #1655 V2 (R19-2), `with_held_grid`: a step of one value curve keeps its jump length while -/// another rides, both ways round. The step at 1,000 jumps over 480; the riding curve's grid -/// points whose ramps would end inside that jump (1,024 to 1,408) are held, and the first one -/// whose ramp ends after it (1,472) retargets again, by the one-cell rule. Without the rule (a -/// route column, R19-3) the riding curve's grid point at 1,024 cuts the jump short. -/// -/// Red if a grid event inside another curve's jump retargets a held-grid output, or if the hold -/// outlasts the jump. -#[test] -fn a_step_keeps_its_jump_length_while_another_curve_rides_on_a_held_grid_output() { - let ride = [seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; - let step = [ - seg(0, 1_000, -6.0, -6.0, Shape::Step), - seg(1_000, 9_000, -12.0, -12.0, Shape::Step), - ]; - for order in [[&ride[..], &step[..]], [&step[..], &ride[..]]] { - let program = - ProgramBuilder::build(&[fader_cell(order[0]), fader_cell(order[1])]).expect("program"); - let bind = |held: bool| { - let composed = Composed::::new( - &program, - &[(0, CursorKind::Value), (1, CursorKind::Value)], - Completion::End, - ) - .expect("bind"); - let mut composed = if held { - composed.with_held_grid() - } else { - composed - }; - composed.seed(summed); - for cursor in composed.cursors_mut() { - cursor.set_jump(480); - } - composed - }; - let after_step = |events: &[(u32, EventKind, u32)]| { - events - .iter() - .copied() - .filter(|&(offset, _, _)| offset >= 1_000) - .take(2) - .collect::>() - }; - let (held, _) = retargets(&program, &mut bind(true), summed, 1_000); - assert_eq!( - after_step(&held), - [(1_000, EventKind::Jump, 480), (1_472, EventKind::Grid, 64)] - ); - let (free, _) = retargets(&program, &mut bind(false), summed, 1_000); - assert_eq!( - after_step(&free), - [(1_000, EventKind::Jump, 480), (1_024, EventKind::Grid, 64)] - ); - } -} From 88ebad27602024efc4c2f878d75c98acc7abdb0f Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 15:57:28 +0000 Subject: [PATCH 23/36] Add placeholder TBD53: decide how adoption treats a rounding-only table difference (phase 2) Co-Authored-By: Claude Opus 5.5 --- ...treats-a-rounding-only-table-difference.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/ISSUE_SPECS/TBD53-decide-how-adoption-treats-a-rounding-only-table-difference.md diff --git a/.github/ISSUE_SPECS/TBD53-decide-how-adoption-treats-a-rounding-only-table-difference.md b/.github/ISSUE_SPECS/TBD53-decide-how-adoption-treats-a-rounding-only-table-difference.md new file mode 100644 index 000000000..db55393b6 --- /dev/null +++ b/.github/ISSUE_SPECS/TBD53-decide-how-adoption-treats-a-rounding-only-table-difference.md @@ -0,0 +1,41 @@ +# Decide how adoption treats a rounding-only table difference + +Placeholder from batch 19 (#1711 F2 and F3/F4 decision records; controls coordinator, 2026-10-17). +Phase 2. Root files it. + +## Product outcome + +A table-only rebuild that changes no value a user can hear (for example a ride split at a sample, so +the same line is stored as two segments) leaves the rendered bits as they were, or the difference is a +documented, measured choice. + +## Context + +- At a Reposition adoption the fader cell compares the stage's target with the new curve (#1711 F0) + and, if they differ, jumps over the fader length `J` (#1648 D2; after #1711, at the move's first free + sample). +- A split table evaluates the same line in `f32` from different segment ends, so its values can differ + from the old table's in the last bits. The comparison then sees a moved target and the cell jumps over + `J`, where a fresh plan with the new table never jumps. +- Measured on batch 19 (`a_level_move_across_a_table_change_keeps_its_length` setup, C ABI): a + table-only rebuild with no live move differs from the uninterrupted engine by about 1.7e-4 three + blocks after the swap (1.4e-4 after a second rebuild); with a 500-sample move, 7.7e-5. The watermark + reports `OUTCOME_EXACT`, which is honest under D2 (the jump is the specified adoption behaviour). +- Because of this, the G1 case "two table-only rebuilds 5 blocks apart" is gated on its outcome only. + +## Decision to make at filing + +- Option A: keep D2 as it is and document that a rounding-only difference is landed over `J`. +- Option B: compare with a tolerance tied to the curve's own `f32` evaluation error, or compare the + segment parameters, so a rounding-only difference does not jump. +- Option C: make the table builder canonical (no split of a straight line), so the values are equal. + +## Objective gates + +- G1. The two-rebuild case of #1711 G1 is bit-equal to the uninterrupted engine (B or C), or a gate + pins the documented jump (A). +- G2. A real table change still lands over `J` (the #1711 gates stay green). + +## Dependencies + +- #1711, #1648 D2. From 45f5903265464c236ed727fa649e5dd9435ab842 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 15:57:48 +0000 Subject: [PATCH 24/36] Add placeholder TBD54: land a removed send row exactly across a live move (phase 2, B19-R7) Co-Authored-By: Claude Opus 5.5 --- ...ved-send-row-exactly-across-a-live-move.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md diff --git a/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md b/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md new file mode 100644 index 000000000..cb7db16fd --- /dev/null +++ b/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md @@ -0,0 +1,41 @@ +# Land a removed send row exactly across a live move + +Placeholder from batch 19 (#1654 U2 review, root ruling B19-R7; controls coordinator, 2026-10-17). +Phase 2. Root files it. It follows the "split a blocking gate early" practice: #1654 closes on its +passing gates with a conservative flag, and the exact case moves here. + +## Product outcome + +When a rebuild removes a send's route-row automation (or its source strip's mute automation) while the +send stays a seven-word route and a live send move (a hand gain offset or an un-automated row edit) is +still ramping on that column, the move keeps its own length and the column then lands on the new +static value over the removed row's own length. The revision's watermark reports `OUTCOME_EXACT`. This +issue deletes the conservative flag of B19-R7. + +## Context + +- Batch 19 (#1654 after B19-R7): a carried seven-word following route that loses a route row or its + source-mute curve lands the column at once over the removed row's `J`. That can cut a live send move + in flight, so the control plane marks such a rebuild `CandidateOutcome::TransitionFallback` while a + live send move may still ramp (send-only, conservative; the doc text sits beside #1713's). +- Two exact attempts failed review (U2 folds 1 and 2): an "owed landing" held per cursor leaked its + length to later jumps; held per column, it was dropped by a further rebuild (`adopt` reset it, + MAJOR-C) and broken by a seek (its time was a node time computed from a render-time remaining, and a + seek dropped it, leaving a stale value: MAJOR-D). Probes: the batch 19 verifier's + `scratchpad/u2f2v/probes*.rs` (session scratchpad; summarised here). +- The design this issue must hold: an owed landing kept in render terms (samples left on the column's + ramp, counted down by `frames`), carried by every carry (merged with a new one by the longer length, + R19-4), never dropped by a seek unless the seek's landing composes that column, and never leaving a + value that differs from a fresh plan's after its landing. + +## Objective gates + +- G1. The batch 19 MAJOR-A, MAJOR-C (two rebuilds) and MAJOR-D (seek inside, forward and back) setups: + bit-equal to the uninterrupted engine until the move's end, then to a fresh plan; `OUTCOME_EXACT`; + both hosts; host-core at Simd8 and Simd4 and the scalar oracle. +- G2. The B19-R7 flag and its doc text are deleted; its gates are replaced by G1. +- G3. Allocation-free render; no new panic path. + +## Dependencies + +- #1654 (batch 19), #1713 (the seven-to-six flag, separate). From be4eb1555b5413104f4c686e12dc67b8a9072d0e Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 16:09:52 +0000 Subject: [PATCH 25/36] OR automated VCA mutes into a following send's follow term (#1655 V4) A following send of a lane an automated VCA mute reaches walks the VCA mute curves as gate cursors in the same column table, in the tap's node time, over the VcaMute length; a lane only a VCA mute reaches now prepares. VCA mute cursors carry verbatim when the lane continues and a(tap) holds, else they are placed fresh; a moved gate retargets over its own row's length. Graph gates for tap time, the OR, R19-3 holds and the carry. Co-Authored-By: Claude Opus 5.5 --- ...r-stored-vca-offset-and-mute-automation.md | 60 ++ crates/graph/src/lib.rs | 110 ++- crates/graph/src/runtime.rs | 658 ++++++++++++++++-- crates/host-core/src/prepare.rs | 57 +- 4 files changed, 786 insertions(+), 99 deletions(-) diff --git a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md index 225a8e3f0..f444f660f 100644 --- a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md +++ b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md @@ -601,3 +601,63 @@ regions in 35 files (unchanged). For V4: read `follow_vca_mute_cells` in the attach loop (`prepare.rs`, the `follow_cells` push) and pass its lists into `GraphFollowRouteSource`; jump row `VcaMute`. For V5: host parity, the cap gate (R19-6), gates 1-3 and 7-8 on both hosts. + +## Decision record: V4 (batch 19) + +The route op's VCA mute follow term (`crates/graph/src/{lib.rs,runtime.rs}`) and the attach in +`crates/host-core/src/prepare.rs` (the `follow_cells` loop and `follow_route_jumps`). No edit under +`crates/engine/src/realtime/`, in `crates/automation` or in any row class or table (no SDK +regeneration). + +- **Source (D4).** `GraphFollowRouteSource::new` takes `vca_mutes: [Vec; 2]` (per source lane, + V3's `follow_vca_mute_cells`, ascending VCA ID; stored boxed) and checks `flags.automated[lane] == + own cell || VCA cells`, every cell in the program. This closes V3's interim refusal: a send whose + lane only a VCA mute reaches now prepares. `with_continued_vca_mutes([bool; 2])` (host-core: + `controls::continued_mute_lanes` of the successor) marks lanes whose VCA mute tables continue. + When the source strip has no own mute cell and the route no row cell, the attach takes the VCA + cells' program (`follow_vca_mute_cells` now takes `&mut Option>`). +- **Jump row (data).** `GraphRouteCursorRow::VcaMute` and `GraphRouteJumpLengths::vca_mute`; + host-core's rule reads `LiveRamps::for_row(VcaMute)`. +- **Walk.** The VCA mute cursors are `Gate` cursors appended after the five role cursors in each + column's one K1 table (`ColumnRoles { fixed, vca_start, vca_count }`); no second walk. The follow + term is `follows_mute && (own curve || v_1 || ... || v_m || terms)` in `column_target`, in the + tap's node time. Because they are gates, a VCA mute fade holds the column's value events and + lands them at its end (R19-3, K1's deferral). The table (and its probe twin) is boxed, allocated + at attach, charged by `Composed::bytes(K)`; the source's lists are added to the producer's + `cursor_bytes`. +- **Carry (D6, U2 folds).** A lane's VCA mute cursors carry verbatim by position (`Continued`) when + the lane continues and `a(tap)` did not move; else they start fresh and are placed at the first + block like the source mute. At the first block the follow curves are one gate: its OR is + compared with the OR the predecessor heard (with the own mute alone this is the old per-cursor + comparison, so no bit moves). A gate move takes the longest own length among the curves that + close the column (for an open, among all follow curves; which were closed is not kept across a + carry), R19-4. A follow curve is "dropped" (MAJOR-3, owed landing of U2 fold 2) only when no + own or VCA mute curve is left; a dropped VCA mute lands over `VcaMute`. A seven -> six carry of + a predecessor that heard VCA curves reopens over `max(mute, VcaMute)`. + +**Departure.** In `place_carried` a moved gate now retargets over its own row's length (was the +mute length for every gate; equal for the source and route mutes, so no existing bit moves). + +Gates (`cargo test --locked -p graph --features test-support`, 149 lib tests green), module +`runtime::send_automation_tests`, `VcaMute` = 320 against the mute rule's 200: +`a_vca_mute_step_closes_a_following_send_at_its_tap_time` (a lane only a VCA mute reaches, `a(tap)` +0 and 100: the close lands at render sample `1,000 + a(tap)` over 320), +`a_vca_mute_ors_with_the_source_lanes_own_mute`, `a_vca_unmute_fade_holds_the_columns_value_events_to_its_end` +(R19-3), `a_carried_vca_mute_renders_the_twins_bits_continued_or_repositioned` (red with the +continued cursor's state dropped: block 21), `a_moved_vca_mute_table_lands_over_its_own_length_at_the_first_sample` +(both ways, the OR with a closed own mute, a removed VCA mute); `follow_route_tests`: the source +accepts VCA-only lanes and refuses a VCA cell outside the program and disagreeing flags. + +Commands: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` on graph, host-core, +automation; `cargo check --locked --all-targets` (workspace, no features); tests automation, graph +`--features test-support`, host-core `--features test-support` (all, incl. `route_mute` 26, +`stored_vca_automation`; with `control-provider` 6), capi lib (220), host-web `--features +test-support --test control_parity` (44): all green. Realtime policy: 172 regions in 35 files +(unchanged). `scripts/check-graph-policy.sh`: PASS. Sizes (x86-64, measured): +`LIVE_FOLLOW_ROUTE_OWNER_BYTES` 1,072 -> 1,120, `GraphFollowRouteSource` 120 -> 152, +`GraphFollowRouteControlBinding` 808 -> 872, `GraphFollowRouteControlProducer` 104; charges read +`size_of`. + +For V5: host gates for gate 3 (a following send of a member closes at its tap under a VCA mute, +both hosts, latent tap), a member with no own mute entry, a nested VCA, the carry (continued and +moved VCA mute tables) and the reach-pair cap gate (R19-6). diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 47d552dea..4f8a53d0a 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1421,12 +1421,14 @@ impl FollowRouteControlLane { pub const GRAPH_ROUTE_ROWS: usize = 6; /// Which jump length one cursor of a following route takes (#1654 D1, batch 19 spec check): the -/// mute rule for a source lane's mute and for the route's own mute, `SendGain` for the route's -/// gain and `SendMatrix` for a matrix entry. +/// mute rule for a source lane's mute and for the route's own mute, `VcaMute` for a reaching +/// VCA's mute (#1655 D4), `SendGain` for the route's gain and `SendMatrix` for a matrix entry. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum GraphRouteCursorRow { /// A source lane's stored mute (#1652). SourceMute, + /// The stored mute of an automated VCA that reaches the source lane (#1655 D4). + VcaMute, /// The route's own `gain_db` (row 1). Gain, /// The route's own `mute` (row 2). @@ -1441,6 +1443,8 @@ pub enum GraphRouteCursorRow { pub struct GraphRouteJumpLengths { /// The mute row's length: a source lane's mute and the route's own mute. pub mute: u32, + /// The `VcaMute` row's length: a reaching VCA's mute curve (#1655 D4). + pub vca_mute: u32, /// The `SendGain` row's length. pub gain: u32, /// The `SendMatrix` row's length. @@ -1454,6 +1458,7 @@ impl GraphRouteJumpLengths { pub const fn for_row(self, row: GraphRouteCursorRow) -> u32 { match row { GraphRouteCursorRow::SourceMute | GraphRouteCursorRow::Mute => self.mute, + GraphRouteCursorRow::VcaMute => self.vca_mute, GraphRouteCursorRow::Gain => self.gain, GraphRouteCursorRow::Matrix => self.matrix, } @@ -1462,9 +1467,10 @@ impl GraphRouteJumpLengths { } /// The jump lengths of a following route's cursor rows, resolved from the block's three session -/// lengths (#1652b, #1654 D1). Host-core hands in the row rules (`controls::LiveRamps::from_lengths` -/// and `for_row` of `LiveRampRow::Mute`, `SendGain` and `SendMatrix`, the rules the strip's stages -/// use), so graph never spells a row and the strip ramp and the send ramp cannot differ in length. +/// lengths (#1652b, #1654 D1, #1655 D4). Host-core hands in the row rules +/// (`controls::LiveRamps::from_lengths` and `for_row` of `LiveRampRow::Mute`, `VcaMute`, +/// `SendGain` and `SendMatrix`, the rules the strip's stages use), so graph never spells a row and +/// the strip ramp and the send ramp cannot differ in length. pub type GraphRouteJumpRule = fn(GraphJumpLengths) -> GraphRouteJumpLengths; /// The route's dB law for an automated send gain (#1654, batch 10 amendment): the linear gain of @@ -1474,10 +1480,11 @@ pub type GraphRouteJumpRule = fn(GraphJumpLengths) -> GraphRouteJumpLengths; /// `route_transform` calls for a static gain, so the two are one derivation. pub type GraphRouteGainLaw = fn(curve_db: f32, offset_db: f32) -> f32; -/// What one following route reads of its stored automation (#1652 D2, #1652b, #1654 D2): the -/// plan's immutable automation program, the source lanes' mute cells in it, the route's own row -/// cells in it, the arrival `a(tap)` of the route's tap, the cursor rows' jump-length rule, the -/// route's gain law and the seeded flags word. +/// What one following route reads of its stored automation (#1652 D2, #1652b, #1654 D2, #1655 +/// D4): the plan's immutable automation program, the source lanes' mute cells in it, the cells of +/// the automated VCA mutes that reach each source lane, the route's own row cells in it, the +/// arrival `a(tap)` of the route's tap, the cursor rows' jump-length rule, the route's gain law +/// 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 @@ -1488,6 +1495,9 @@ pub type GraphRouteGainLaw = fn(curve_db: f32, offset_db: f32) -> f32; pub struct GraphFollowRouteSource { pub(crate) program: std::sync::Arc, pub(crate) cells: [Option; 2], + /// Per source lane, the mute cells of the automated VCAs that reach it, in ascending VCA ID + /// (#1655 D4): the route ORs them into the lane's follow term at `a(tap)`. + pub(crate) vca_mutes: [Box<[u32]>; 2], pub(crate) route_cells: [Option; GRAPH_ROUTE_ROWS], pub(crate) arrival: u64, pub(crate) jumps: GraphRouteJumpRule, @@ -1496,6 +1506,9 @@ pub struct GraphFollowRouteSource { /// Per route row, whether the predecessor plan holds the same table for it, so a carry keeps /// its cursor verbatim ([`Self::with_continued_rows`]). pub(crate) continued: [bool; GRAPH_ROUTE_ROWS], + /// Per source lane, whether the predecessor plan holds the same VCA mute tables for it, in + /// the same order ([`Self::with_continued_vca_mutes`]). + pub(crate) continued_vca: [bool; 2], /// The seeded hand offset in dB of an automated `gain_db` (#1654, batch 10 amendment): the /// cell's `gain` word before any record. `+0.0` when the flags do not automate the gain. pub(crate) gain_offset: f32, @@ -1503,48 +1516,72 @@ pub struct GraphFollowRouteSource { 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; `route_cells[row]` is the route's - /// own row cell in `program` (order [`GRAPH_ROUTE_ROWS`]), `None` for a row the plan does not - /// automate; `arrival` is `a(tap)`; `jumps` resolves the cursor rows' jump lengths; `gain_law` - /// is the route's dB law; `flags` is the seeded flags word. + /// `program`, `None` for a lane whose own mute the plan does not automate; `vca_mutes[lane]` + /// are the mute cells in `program` of the automated VCAs that reach the lane, in ascending VCA + /// ID (#1655 D4, empty when none does); `route_cells[row]` is the route's own row cell in + /// `program` (order [`GRAPH_ROUTE_ROWS`]), `None` for a row the plan does not automate; + /// `arrival` is `a(tap)`; `jumps` resolves the cursor rows' jump lengths; `gain_law` is the + /// route's dB law; `flags` is the seeded flags word. /// /// `None` when no source lane and no route row is automated (the route keeps the six-word - /// cell), when a cell is not in `program`, or when `flags.automated` disagrees with `cells` or - /// `flags.route_rows()` with `route_cells` (the plan fixes which lanes and rows are automated, - /// D1). + /// cell), when a cell is not in `program`, or when `flags.automated` disagrees with the lanes + /// that have a curve (own cell or VCA mute cells) or `flags.route_rows()` with `route_cells` + /// (the plan fixes which lanes and rows are automated, D1). #[must_use] + #[allow( + clippy::too_many_arguments, + reason = "the plan-fixed parts of one source, each its own type" + )] pub fn new( program: std::sync::Arc, cells: [Option; 2], + vca_mutes: [Vec; 2], route_cells: [Option; GRAPH_ROUTE_ROWS], arrival: u64, jumps: GraphRouteJumpRule, gain_law: GraphRouteGainLaw, flags: RouteFollowFlags, ) -> Option { - let automated = cells.map(|cell| cell.is_some()); + let automated = [0, 1].map(|lane| { + cells.get(lane).copied().flatten().is_some() + || vca_mutes.get(lane).is_some_and(|list| !list.is_empty()) + }); let rows = route_cells.map(|cell| cell.is_some()); let in_program = cells .iter() .chain(&route_cells) .flatten() + .chain(vca_mutes.iter().flatten()) .all(|&cell| program.cell(cell as usize).is_some()); let any = automated.contains(&true) || rows.contains(&true); - (any && in_program && flags.automated == automated && flags.route_rows() == rows).then_some( - Self { + (any && in_program && flags.automated == automated && flags.route_rows() == rows).then( + || Self { program, cells, + vca_mutes: vca_mutes.map(Vec::into_boxed_slice), route_cells, arrival, jumps, gain_law, flags, continued: [false; GRAPH_ROUTE_ROWS], + continued_vca: [false; 2], gain_offset: 0.0, }, ) } + /// This source with `lanes[lane]` set for each source lane whose VCA mute tables the + /// predecessor plan holds unchanged, the same VCAs in the same order (#1655 D6, + /// `controls::continued_mute_lanes`): at a plan swap that lane's VCA mute cursors carry their + /// state and heard values verbatim, where otherwise they are placed again at the successor's + /// node time. By default no lane continues. + #[must_use] + pub const fn with_continued_vca_mutes(mut self, lanes: [bool; 2]) -> Self { + self.continued_vca = lanes; + self + } + /// This source with `rows[row]` set for each route row (order [`GRAPH_ROUTE_ROWS`]) whose /// table the predecessor plan holds unchanged (#1654 D3, the Carry paragraph): at a plan swap /// that row's cursor carries its event state and heard value verbatim, where every other @@ -10700,6 +10737,7 @@ mod follow_route_tests { pub(crate) fn route_jumps(lengths: GraphJumpLengths) -> GraphRouteJumpLengths { GraphRouteJumpLengths { mute: lengths.mute_samples, + vca_mute: lengths.mute_samples, gain: lengths.fader_samples, matrix: lengths.pan_samples, } @@ -10745,6 +10783,7 @@ mod follow_route_tests { GraphFollowRouteSource::new( program, cells, + [Vec::new(), Vec::new()], [None; GRAPH_ROUTE_ROWS], arrival, route_jumps, @@ -10784,10 +10823,11 @@ mod follow_route_tests { assert_eq!(source.arrival(), 7); let program = std::sync::Arc::clone(&source.program); let flags = source.flags(); - let new = |cells, route_cells, flags| { + let new_with = |cells, vca_mutes, route_cells, flags| { GraphFollowRouteSource::new( std::sync::Arc::clone(&program), cells, + vca_mutes, route_cells, 0, route_jumps, @@ -10796,8 +10836,32 @@ mod follow_route_tests { ) .is_some() }; + let new = |cells, route_cells, flags| { + new_with(cells, [Vec::new(), Vec::new()], route_cells, flags) + }; let no_rows = [None; GRAPH_ROUTE_ROWS]; assert!(new([Some(0), None], no_rows, flags)); + // #1655 D4: a lane is automated by its own cell or by a reaching VCA mute's cells. + let both = RouteFollowFlags { + automated: [true, true], + ..flags + }; + assert!( + new_with([Some(0), None], [Vec::new(), vec![0]], no_rows, both), + "a lane only a VCA mute reaches" + ); + assert!( + new_with([None, None], [vec![0, 0], vec![0]], no_rows, both), + "VCA mute cells alone" + ); + assert!( + !new_with([Some(0), None], [Vec::new(), vec![1]], no_rows, both), + "a VCA mute cell outside the program" + ); + assert!( + !new_with([Some(0), None], [vec![0], Vec::new()], no_rows, both), + "VCA flags that disagree" + ); assert!( !new([None, None], no_rows, RouteFollowFlags::default()), "no lane" @@ -10888,6 +10952,7 @@ mod follow_route_tests { GraphFollowRouteSource::new( std::sync::Arc::clone(&program), [None, None], + [Vec::new(), Vec::new()], [gain.then_some(0), None, Some(0), None, None, None], 0, route_jumps, @@ -10912,17 +10977,20 @@ mod follow_route_tests { } /// The cursor rows' jump lengths: the source mute and the route mute take the mute length, - /// the gain the `SendGain` length and a matrix entry the `SendMatrix` length. + /// a VCA mute the `VcaMute` length, the gain the `SendGain` length and a matrix entry the + /// `SendMatrix` length. /// /// Test value: red if a route cursor would jump over another row's length. #[test] fn each_cursor_row_takes_its_own_jump_length() { let lengths = GraphRouteJumpLengths { mute: 3, + vca_mute: 4, gain: 5, matrix: 7, }; assert_eq!(lengths.for_row(GraphRouteCursorRow::SourceMute), 3); + assert_eq!(lengths.for_row(GraphRouteCursorRow::VcaMute), 4); assert_eq!(lengths.for_row(GraphRouteCursorRow::Mute), 3); assert_eq!(lengths.for_row(GraphRouteCursorRow::Gain), 5); assert_eq!(lengths.for_row(GraphRouteCursorRow::Matrix), 7); diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index 094a60081..44e2efde7 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -1456,7 +1456,9 @@ impl ColumnRamps { // REALTIME_POLICY_END /// The roles of the curves one source column hears (#1654 T3): its source lane's stored mute, -/// the route's own mute, the route's gain and the column's two matrix entries, in that order. +/// the route's own mute, the route's gain and the column's two matrix entries, in that order. The +/// mute curves of the automated VCAs that reach the source lane (#1655 D4) follow them in the +/// column's table, any number of them ([`ColumnRoles::vca`]). const ROLES: usize = 5; const SOURCE_MUTE: usize = 0; const ROUTE_MUTE: usize = 1; @@ -1468,15 +1470,42 @@ const MATRIX: [usize; 2] = [3, 4]; /// whether its gate silences it. type ColumnTarget = ([f32; 2], bool); -/// Which cursor of a column's table carries each role, by role; `None` for a role the plan does -/// not automate. +/// Which cursor of a column's table carries each role, by role (`None` for a role the plan does +/// not automate), and the cursors of the reaching VCA mute curves (#1655 D4), which follow every +/// role's cursor in the table, in ascending VCA ID. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -struct ColumnRoles([Option; ROLES]); +struct ColumnRoles { + fixed: [Option; ROLES], + /// The first VCA mute cursor's index. + vca_start: usize, + /// How many VCA mute cursors the column holds. + vca_count: usize, +} impl ColumnRoles { /// The cursor index of `role`. fn of(self, role: usize) -> Option { - self.0.get(role).copied().flatten() + self.fixed.get(role).copied().flatten() + } + + /// The cursor indices of the column's VCA mute curves (#1655 D4). + const fn vca(self) -> core::ops::Range { + self.vca_start..self.vca_start.saturating_add(self.vca_count) + } + + /// Whether the column hears a curve of its source lane's follow term: the lane's own mute or + /// a reaching VCA mute. + fn has_follow(self) -> bool { + self.of(SOURCE_MUTE).is_some() || self.vca_count > 0 + } + + /// The curves' share of the column's follow term under the values `value(cursor)` (#1652 D2, + /// #1655 D4): the source lane's own mute curve OR each reaching VCA mute curve. `false` with + /// no such curve. + fn follow_curve(self, value: impl Fn(usize) -> f32) -> bool { + self.of(SOURCE_MUTE) + .is_some_and(|index| value(index) != 0.0) + || self.vca().any(|index| value(index) != 0.0) } /// The jump-length row of `role`: the mute rule for both mutes, `SendGain` for the gain, @@ -1518,11 +1547,12 @@ enum Placement { New, } -/// The cells of `column`'s cursor table under `source`, their count and their roles. Control side. +/// The cells of `column`'s cursor table under `source` and their roles: the roles' cursors, then +/// the source lane's VCA mute cursors (#1655 D4). Control side: it allocates the list. fn column_cells( source: &crate::GraphFollowRouteSource, column: usize, -) -> ([(u32, automation::CursorKind); ROLES], usize, ColumnRoles) { +) -> (Vec<(u32, automation::CursorKind)>, ColumnRoles) { use automation::CursorKind::{Gate, Value}; let indices = COLUMNS.get(column).copied().unwrap_or([0, 2]); let route_cell = |row: usize| source.route_cells.get(row).copied().flatten(); @@ -1537,30 +1567,45 @@ fn column_cells( (MATRIX[0], route_cell(2 + indices[0]), Value), (MATRIX[1], route_cell(2 + indices[1]), Value), ]; - let mut cells = [(0, Gate); ROLES]; + let vca: &[u32] = source.vca_mutes.get(column).map_or(&[], |list| list); + let mut cells = Vec::with_capacity(ROLES + vca.len()); let mut roles = [None; ROLES]; - let mut count = 0; for (role, cell, kind) in wanted { let Some(cell) = cell else { continue; }; - if let (Some(slot), Some(role_slot)) = (cells.get_mut(count), roles.get_mut(role)) { - *slot = (cell, kind); - *role_slot = Some(count); - count += 1; + if let Some(role_slot) = roles.get_mut(role) { + *role_slot = Some(cells.len()); + cells.push((cell, kind)); } } - (cells, count, ColumnRoles(roles)) + let vca_start = cells.len(); + cells.extend(vca.iter().map(|&cell| (cell, Gate))); + ( + cells, + ColumnRoles { + fixed: roles, + vca_start, + vca_count: vca.len(), + }, + ) } /// The cursor tables bind allocates for one following route of `source` (#1654 T3, R19-3): per -/// column with a curve, its walk's and its probe twin's tables. Returns the total bytes and the -/// largest single table. +/// column with a curve, its walk's and its probe twin's tables (VCA mute cursors included, #1655 +/// D4), plus the source's VCA mute cell lists, which the binding holds until bind. Returns the +/// total bytes and the largest single allocation. pub(crate) fn follow_route_cursor_bytes(source: &crate::GraphFollowRouteSource) -> (u64, u64) { let mut total = 0_u64; let mut largest = 0_u64; + for list in &source.vca_mutes { + let bytes = u64::try_from(core::mem::size_of_val::<[u32]>(list)).unwrap_or(u64::MAX); + total = total.saturating_add(bytes); + largest = largest.max(bytes); + } for column in 0..COLUMNS.len() { - let (_, count, _) = column_cells(source, column); + let (cells, _) = column_cells(source, column); + let count = cells.len(); if count == 0 { continue; } @@ -1580,6 +1625,9 @@ struct RouteColumn { probe: automation::Composed, roles: ColumnRoles, placement: [Placement; ROLES], + /// The placement of every VCA mute cursor (#1655 D4): `Continued` when the lane's VCA mute + /// tables continue, else placed again at the first block's node time. + vca_placement: Placement, } impl RouteColumn { @@ -1596,12 +1644,12 @@ impl RouteColumn { source: &crate::GraphFollowRouteSource, column: usize, ) -> Result, automation::ComposeError> { - let (cells, count, roles) = column_cells(source, column); - let Some(cells) = cells.get(..count).filter(|cells| !cells.is_empty()) else { + let (cells, roles) = column_cells(source, column); + if cells.is_empty() { return Ok(None); - }; + } let table = || { - automation::Composed::new(&source.program, cells, automation::Completion::End) + automation::Composed::new(&source.program, &cells, automation::Completion::End) .map(automation::Composed::with_held_grid) }; Ok(Some(Self { @@ -1609,6 +1657,7 @@ impl RouteColumn { probe: table()?, roles, placement: [Placement::Continued; ROLES], + vca_placement: Placement::Continued, })) } } @@ -1646,9 +1695,20 @@ struct FollowCursors { jumps: crate::GraphRouteJumpRule, gain_law: crate::GraphRouteGainLaw, continued: [bool; crate::GRAPH_ROUTE_ROWS], + /// Per column, whether the predecessor's VCA mute tables of the source lane continue + /// ([`crate::GraphFollowRouteSource::with_continued_vca_mutes`], #1655 D6). + continued_vca: [bool; 2], /// Per column, the roles the predecessor's column heard and this plan's does not (a removed /// row or source mute curve), set by a carry and read by its first block (U2 fold 1, MAJOR-3). + /// The source mute's slot reads the follow curves as one: the predecessor heard its source + /// mute and this column hears no follow curve (own or VCA mute, #1655 D4). dropped: [[bool; ROLES]; 2], + /// Per column, the predecessor heard VCA mute curves and this column hears no follow curve + /// (#1655 D4): a removed curve that lands over the `VcaMute` length. + dropped_vca: [bool; 2], + /// Per column, the predecessor's heard follow curve ([`ColumnRoles::follow_curve`]), set by a + /// carry: the first block compares the follow curves as one gate (#1655 D4). + follow_heard: [bool; 2], /// Per column, a landing a carry owes it (U2 fold 2): at node time `free`, the first free /// sample of the column ramp in flight at the swap, the column retargets to its composition /// over `length` (a removed row's own length). It is the column's, never a cursor's; any @@ -1696,6 +1756,7 @@ impl FollowBlock { frames: 0, lengths: crate::GraphRouteJumpLengths { mute: 0, + vca_mute: 0, gain: 0, matrix: 0, }, @@ -1742,7 +1803,10 @@ impl LiveFollowRoute { jumps: source.jumps, gain_law: source.gain_law, continued: source.continued, + continued_vca: source.continued_vca, dropped: [[false; ROLES]; 2], + dropped_vca: [false; 2], + follow_heard: [false; 2], owed: [None; 2], start: FollowStart::Fresh, }; @@ -1823,7 +1887,8 @@ const fn transform_bits(transform: &crate::RouteTransform) -> [u32; 5] { /// automated values from the curves the column hears (`value(cursor)`): the gain through the /// route's gain law with the record's hand offset (`law(curve, offset)`), each automated matrix /// entry of the column from its curve; gated by `mute = static mute || route mute curve` (D5, -/// an OR) and the column's follow term `follows_mute && (source curve || terms)`; +/// an OR) and the column's follow term `follows_mute && (source curve || VCA mute curves || +/// terms)` (#1655 D4: each reaching automated VCA mute in the tap's node time); /// [`gated_route_coefficients`] of that, the column's entries. The one composition of the seed, /// the cell's block entry, a record's target and every curve event, so their bits cannot differ. /// A column's entries do not depend on the other column's gate, so the two columns compose apart. @@ -1856,8 +1921,7 @@ fn column_target( } let mute = flags.mute || heard(ROUTE_MUTE).is_some_and(|curve| curve != 0.0); let zeroed = flags.follows_mute - && (heard(SOURCE_MUTE).is_some_and(|curve| curve != 0.0) - || flags.terms.get(column).copied().unwrap_or(false)); + && (roles.follow_curve(&value) || flags.terms.get(column).copied().unwrap_or(false)); let mut follow_zeroed = [false; 2]; if let Some(slot) = follow_zeroed.get_mut(column) { *slot = zeroed; @@ -1872,6 +1936,18 @@ fn column_target( (column_entries(all, column), mute || zeroed) } +impl RouteColumn { + /// The follow curves' share the column has heard ([`ColumnRoles::follow_curve`]). + fn heard_follow(&self) -> bool { + let cursors = self.walk.cursors(); + self.roles.follow_curve(|index| { + cursors + .get(index) + .map_or(0.0, automation::ComposeCursor::heard) + }) + } +} + impl FollowCursors { /// Column `column`'s target composed with the values it has heard (a column with no curve: /// the cell's words alone). @@ -1884,8 +1960,9 @@ impl FollowCursors { } } - /// Per column, whether the source curve the route has heard zeroes it under `flags`: the - /// curve's share of the follow term (`follows_mute && automated && curve`). + /// Per column, whether the source curves the route has heard zero it under `flags`: the + /// curves' share of the follow term (`follows_mute && automated && (own curve || VCA mute + /// curves)`, #1655 D4). fn heard_zeroed(&self, flags: crate::RouteFollowFlags) -> [bool; 2] { [0, 1].map(|column| { flags.follows_mute @@ -1893,14 +1970,7 @@ impl FollowCursors { .columns .get(column) .and_then(Option::as_ref) - .and_then(|walk| { - let index = walk.roles.of(SOURCE_MUTE)?; - walk.walk - .cursors() - .get(index) - .map(|cursor| cursor.heard() != 0.0) - }) - .unwrap_or(false) + .is_some_and(RouteColumn::heard_follow) }) } @@ -1921,6 +1991,7 @@ impl FollowCursors { let bound = |length: u32| core::cmp::min(length, crate::ROUTE_RAMP_LENGTH_MAXIMUM); let lengths = crate::GraphRouteJumpLengths { mute: bound(rule.mute), + vca_mute: bound(rule.vca_mute), gain: bound(rule.gain), matrix: bound(rule.matrix), }; @@ -1934,6 +2005,12 @@ impl FollowCursors { cursor.set_jump(lengths.for_row(ColumnRoles::row(role))); } } + // #1655 D4: each reaching VCA mute curve jumps over the `VcaMute` row's length. + for index in column.roles.vca() { + if let Some(cursor) = column.walk.cursor_mut(index) { + cursor.set_jump(lengths.vca_mute); + } + } } let idle = FollowBlock { lengths, @@ -2355,34 +2432,86 @@ impl LiveFollowRoute { /// ([`crate::GraphFollowRouteSource::with_continued_rows`]) keeps them verbatim; every other /// cursor is placed at this plan's first block (a cursor the predecessor did not hold starts /// with no target), after its cell applies with the heard values, and the block entry then - /// compares. + /// compares. A source lane's VCA mute cursors (#1655 D4, D6) carry verbatim by position when + /// the lane's VCA mute tables continue + /// ([`crate::GraphFollowRouteSource::with_continued_vca_mutes`]) and `a(tap)` did not move; + /// else they start fresh and are placed at the first block. The follow curves heard (own and + /// VCA mutes ORed) are kept for the first block's comparison. fn adopt(&mut self, ramps: ColumnRamps, mute: bool, from: Option<&Self>) { self.ramps = ramps; self.mute = mute; self.follow.silent = from.map_or([mute; 2], |from| from.follow.silent); self.follow.owed = [None; 2]; let continued = self.follow.continued; - for (column, (walk, dropped)) in self - .follow - .columns - .iter_mut() - .zip(&mut self.follow.dropped) - .enumerate() - { + // #1655 D6: a lane's VCA mute cursors continue verbatim only when their tables do and + // the tap's arrival did not move (their node time is the tap's). + let same_arrival = from.is_some_and(|from| from.follow.arrival == self.follow.arrival); + let FollowCursors { + columns, + dropped, + dropped_vca, + follow_heard, + continued_vca, + .. + } = &mut self.follow; + for (column, (walk, dropped)) in columns.iter_mut().zip(dropped).enumerate() { let previous = from .and_then(|from| from.follow.columns.get(column)) .and_then(Option::as_ref); - // The roles the predecessor's column heard that this column does not (MAJOR-3). + // The roles the predecessor's column heard that this column does not (MAJOR-3). The + // follow curves (own and VCA mutes) are one gate: dropped only when none is left. + let has_follow = walk.as_ref().is_some_and(|walk| walk.roles.has_follow()); for (role, slot) in dropped.iter_mut().enumerate() { let had = previous.is_some_and(|previous| previous.roles.of(role).is_some()); - let has = walk - .as_ref() - .is_some_and(|walk| walk.roles.of(role).is_some()); + let has = if role == SOURCE_MUTE { + has_follow + } else { + walk.as_ref() + .is_some_and(|walk| walk.roles.of(role).is_some()) + }; *slot = had && !has; } + let previous_vca = previous.map_or(0, |previous| previous.roles.vca_count); + if let Some(slot) = dropped_vca.get_mut(column) { + *slot = previous_vca > 0 && !has_follow; + } + if let Some(slot) = follow_heard.get_mut(column) { + *slot = previous.is_some_and(RouteColumn::heard_follow); + } let Some(walk) = walk else { continue; }; + // #1655 D4, D6: the VCA mute cursors, verbatim by position when the lane's tables + // continue (the same VCAs in the same order), else fresh, placed at the first block. + let vca_continues = continued_vca.get(column).copied().unwrap_or(false) + && same_arrival + && previous_vca == walk.roles.vca_count; + let previous_vca_start = previous.map_or(0, |previous| previous.roles.vca_start); + for (offset, index) in walk.roles.vca().enumerate() { + let carried = previous.filter(|_| vca_continues).and_then(|previous| { + previous + .walk + .cursors() + .get(previous_vca_start.saturating_add(offset)) + }); + if let Some(cursor) = walk.walk.cursor_mut(index) { + match carried { + Some(carried) => { + *cursor.state_mut() = *carried.state(); + cursor.set_heard(carried.heard()); + } + None => { + *cursor.state_mut() = automation::CellState::new(); + cursor.set_heard(0.0); + } + } + } + } + walk.vca_placement = if vca_continues { + Placement::Continued + } else { + Placement::Reposition + }; for role in 0..ROLES { let Some(index) = walk.roles.of(role) else { continue; @@ -2440,6 +2569,12 @@ impl LiveFollowRoute { /// it). A column with no curve compares its cell's target over the mute length, at once; one /// that lost a row lands over that row's length, at once when settled, else owed at the /// column ramp's first free sample (U2 fold 2, MAJOR-A). + /// + /// #1655 D4: the follow curves (the source lane's own mute and every reaching VCA mute) are + /// compared as one gate, their OR against the one the predecessor heard; a move takes the + /// longest own length among the curves that close the column (for an open, among all of + /// them: the mute length for the own mute, `VcaMute` for a VCA mute, R19-4). A VCA mute + /// curve removed with no follow curve left lands over the `VcaMute` length. fn place_carried(&mut self, at: i64) { let Self { ramps, @@ -2462,17 +2597,20 @@ impl LiveFollowRoute { // The longest own length of the rows and curves the column no longer hears // (MAJOR-3): a removed row lands over its own length, a removed mute curve over the // mute length. + let dropped_vca = follow.dropped_vca.get(column).copied().unwrap_or(false); let dropped = follow.dropped.get(column).map_or(0, |dropped| { (0..ROLES) .filter(|&role| dropped.get(role).copied().unwrap_or(false)) .map(|role| lengths.for_row(ColumnRoles::row(role))) + .chain(dropped_vca.then_some(lengths.vca_mute)) .max() .unwrap_or(0) }); - let has_dropped = follow - .dropped - .get(column) - .is_some_and(|dropped| dropped.iter().any(|&role| role)); + let has_dropped = dropped_vca + || follow + .dropped + .get(column) + .is_some_and(|dropped| dropped.iter().any(|&role| role)); let remaining = ramps.remaining(column); let free = at.saturating_add(i64::from(remaining)); let Some(walk) = follow.columns.get_mut(column).and_then(Option::as_mut) else { @@ -2497,6 +2635,9 @@ impl LiveFollowRoute { continue; }; let mut gate_moved = false; + // The longest own length of the gates that moved (R19-4): the mute length for the + // route's own mute and the source mute, `VcaMute` for a VCA mute (#1655 D4). + let mut gate_length = 0_u32; let mut any_placed = false; // The longest own length of a placed value whose curve moved from the value the // column heard (a landing coincident with a removed row's, R19-4). @@ -2527,7 +2668,11 @@ impl LiveFollowRoute { let value = automation::value_at(cell.segments(), at); cursor.state_mut().reposition(&cell, at); if cursor.kind() == automation::CursorKind::Gate { - gate_moved |= (cursor.heard() != 0.0) != (value != 0.0); + // The source mute is one of the follow curves, compared as one below. + if role != SOURCE_MUTE && (cursor.heard() != 0.0) != (value != 0.0) { + gate_moved = true; + gate_length = core::cmp::max(gate_length, own); + } cursor.state_mut().seed_target(value); } else if cursor.state().target().map(f32::to_bits) != Some(value.to_bits()) { moved = core::cmp::max(moved, own); @@ -2535,6 +2680,50 @@ impl LiveFollowRoute { cursor.set_heard(value); } let roles = walk.roles; + // #1655 D4: the VCA mute gates that do not continue are placed as the source mute. + if walk.vca_placement != Placement::Continued { + for index in roles.vca() { + let Some(cursor) = walk.walk.cursor_mut(index) else { + continue; + }; + let Some(cell) = follow.program.cell(cursor.cell() as usize) else { + continue; + }; + let value = automation::value_at(cell.segments(), at); + cursor.state_mut().reposition(&cell, at); + cursor.state_mut().seed_target(value); + cursor.set_heard(value); + } + } + // The follow curves (own mute OR every VCA mute) are one gate: it moved when its OR + // differs from the one the predecessor heard (#1655 D4; with the own mute alone, + // that cursor's heard value against its curve, as before). + // Its length is the longest among the follow curves that close it (a close) or, for + // an open, among all of them (which ones were closed is not kept across a carry). + if roles.has_follow() { + let heard = follow.follow_heard.get(column).copied().unwrap_or(false); + let now = walk.heard_follow(); + if now != heard { + gate_moved = true; + let cursors = walk.walk.cursors(); + let counts = |index: Option| { + index.is_some_and(|index| { + !now || cursors + .get(index) + .is_some_and(|cursor| cursor.heard() != 0.0) + }) + }; + if counts(roles.of(SOURCE_MUTE)) { + gate_length = core::cmp::max( + gate_length, + lengths.for_row(crate::GraphRouteCursorRow::SourceMute), + ); + } + if roles.vca().any(|index| counts(Some(index))) { + gate_length = core::cmp::max(gate_length, lengths.vca_mute); + } + } + } let target = walk.walk.compose_heard(|heard| { column_target(column, roles, composition, |index| heard.value(index)) }); @@ -2554,7 +2743,7 @@ impl LiveFollowRoute { } } else if gate_moved { // Coincident with a removed row's landing, the longer length (R19-4). - let length = core::cmp::max(jump, dropped); + let length = core::cmp::max(gate_length, dropped); ramps.retarget_column( column, with_column(ramps.target(), column, target.0), @@ -6632,6 +6821,9 @@ struct SixWordCarryCheck { mute: bool, zeroed: [bool; 2], jumps: crate::GraphRouteJumpRule, + /// Whether the predecessor heard a VCA mute curve (#1655 D4): its removal reopens over the + /// longer of the mute and `VcaMute` lengths (R19-4). + vca: bool, } /// One six-word successor's comparison at its first block (#1652 D3, the curve removed), in the @@ -6648,10 +6840,14 @@ fn finish_six_word_carry( lengths: Option, ) { let length = lengths.map_or(0, |lengths| { - core::cmp::min( - (check.jumps)(lengths).for_row(crate::GraphRouteCursorRow::SourceMute), - crate::ROUTE_RAMP_LENGTH_MAXIMUM, - ) + let rule = (check.jumps)(lengths); + let mute = rule.for_row(crate::GraphRouteCursorRow::SourceMute); + let vca = if check.vca { + rule.for_row(crate::GraphRouteCursorRow::VcaMute) + } else { + 0 + }; + core::cmp::min(core::cmp::max(mute, vca), crate::ROUTE_RAMP_LENGTH_MAXIMUM) }); if let Some((target, mute)) = live.apply_pending_heard(snapshot, check.zeroed) { live.retarget_carried(target, mute, length); @@ -6715,6 +6911,12 @@ fn carry_route( return false; }; let (zeroed, jumps) = (from.follow.heard_zeroed(from.flags), from.follow.jumps); + let vca = from + .follow + .columns + .iter() + .flatten() + .any(|column| column.roles.vca_count > 0); if prepared.map(f32::to_bits) == ramp.target.map(f32::to_bits) && zeroed == [false; 2] { into.mute = prepared_mute; return false; @@ -6730,6 +6932,7 @@ fn carry_route( mute: prepared_mute, zeroed, jumps, + vca, }); true } @@ -19856,13 +20059,30 @@ mod send_automation_tests { } /// The curves a send hears: its source lanes' mutes, its gain, its own mute and its matrix - /// entries `[ll, lr, rl, rr]`. + /// entries `[ll, lr, rl, rr]`; per source lane, the mute curves of the automated VCAs that + /// reach it, in ascending VCA ID (#1655 D4). #[derive(Default)] struct Curves { source: [Option>; 2], gain: Option>, mute: Option>, matrix: [Option>; 4], + vca: [Vec>; 2], + /// The tap's arrival `a(tap)`. + arrival: u64, + } + + /// The `VcaMute` row's length in these cases (#1655 D4): 320, unlike the mute rule's 200, so + /// a VCA mute cursor that took another row's length is seen. + const VCA_MUTE: u32 = 320; + + /// The send's cursor rows' jump lengths: [`route_jumps`] with the `VcaMute` row at + /// [`VCA_MUTE`]. + fn send_jumps(lengths: GraphJumpLengths) -> crate::GraphRouteJumpLengths { + crate::GraphRouteJumpLengths { + vca_mute: VCA_MUTE, + ..route_jumps(lengths) + } } struct Send { @@ -19874,7 +20094,7 @@ mod send_automation_tests { } impl Send { - /// A route of `transform` over `curves`, at `a(tap) = 0`, following its source. + /// A route of `transform` over `curves`, at `curves.arrival`, following its source. fn new(curves: &Curves, transform: RouteTransform) -> Self { Self::continuing(curves, transform, [false; GRAPH_ROUTE_ROWS]) } @@ -19884,6 +20104,17 @@ mod send_automation_tests { curves: &Curves, transform: RouteTransform, continued: [bool; GRAPH_ROUTE_ROWS], + ) -> Self { + Self::continuing_vca(curves, transform, continued, [false; 2]) + } + + /// [`Self::continuing`] whose source lanes `continued_vca` continue the predecessor's VCA + /// mute tables (#1655 D6). + fn continuing_vca( + curves: &Curves, + transform: RouteTransform, + continued: [bool; GRAPH_ROUTE_ROWS], + continued_vca: [bool; 2], ) -> Self { let rows: [&Option>; GRAPH_ROUTE_ROWS] = [ &curves.gain, @@ -19898,6 +20129,7 @@ mod send_automation_tests { .iter() .chain(rows.iter().copied()) .flatten() + .chain(curves.vca.iter().flatten()) .collect(); let descriptions: Vec<_> = all .iter() @@ -19922,10 +20154,18 @@ mod send_automation_tests { }; let cells = [index(&curves.source[0]), index(&curves.source[1])]; let route_cells = rows.map(&mut index); + let vca_mutes = curves.vca.each_ref().map(|lane| { + lane.iter() + .map(|_| { + next += 1; + next - 1 + }) + .collect::>() + }); let [gain, mute, ll, lr, rl, rr] = route_cells.map(|cell| cell.is_some()); let flags = RouteFollowFlags { follows_mute: true, - automated: cells.map(|cell| cell.is_some()), + automated: [0, 1].map(|lane| cells[lane].is_some() || !vca_mutes[lane].is_empty()), automated_gain: gain, automated_mute: mute, automated_matrix: [ll, lr, rl, rr], @@ -19934,14 +20174,16 @@ mod send_automation_tests { let source = GraphFollowRouteSource::new( program, cells, + vca_mutes, route_cells, - 0, - route_jumps, + curves.arrival, + send_jumps, law, flags, ) .expect("source") - .with_continued_rows(continued); + .with_continued_rows(continued) + .with_continued_vca_mutes(continued_vca); // The cell's `gain` word of an automated gain is the hand offset: the harness's // `transform.gain` seeds it. let source = if gain { @@ -21157,4 +21399,296 @@ mod send_automation_tests { ); assert_eq!(successor.route.ramps.positions[0], 2_432 - 2_400); } + + /// The coefficients of `transform` with linear gain `gain` whose follow term zeroes the + /// columns `zeroed`. + fn zeroed(transform: RouteTransform, gain: f32, zeroed: [bool; 2]) -> [f32; 4] { + gated_route_coefficients( + &RouteTransform { gain, ..transform }, + RouteGate { + mute: false, + follow_zeroed: zeroed, + }, + ) + } + + /// #1655 D4 (V4): a send whose source's left lane only an automated VCA mute reaches (no own + /// mute curve) closes its left column when the VCA mutes (0 -> 1 at 1,000), in the tap's node + /// time: at render sample `1,000 + a(tap)`, over the `VcaMute` length (320), for `a(tap)` 0 + /// and 100. The right column, which no curve reaches, stays open. + /// + /// Red if the follow term leaves out the VCA mute curves (the send keeps passing audio), if the + /// VCA curve is read in render time instead of the tap's node time, or if its close takes + /// another row's length. + #[test] + fn a_vca_mute_step_closes_a_following_send_at_its_tap_time() { + let transform = RouteTransform { gain: 0.5, ..OPEN }; + let opened = open(OPEN, 0.5); + let closed = zeroed(OPEN, 0.5, [true, false]); + for arrival in [0_u64, 100] { + let curves = Curves { + vca: [vec![step(1_000, 0.0, 1.0)], Vec::new()], + arrival, + ..Curves::default() + }; + let mut send = Send::new(&curves, transform); + let close = 1_000 + arrival; + let (block, offset) = (close / QUANTUM as u64, close % QUANTUM as u64); + for index in 0..block { + send.run(index * QUANTUM as u64); + assert_eq!( + send.route.ramps.ramps, + [IndexedRamp::settled(opened); 2], + "a(tap) {arrival}, block {index}: open" + ); + } + send.run(block * QUANTUM as u64); + let ramps = send.route.ramps; + assert!( + aims(&ramps.ramps[0], closed, VCA_MUTE, 0), + "a(tap) {arrival}: the left column closes over the VcaMute length" + ); + assert_eq!( + u64::from(ramps.positions[0]), + QUANTUM as u64 - offset, + "a(tap) {arrival}: the close is at render sample {close}" + ); + assert_eq!( + ramps.ramps[1], + IndexedRamp::settled(opened), + "a(tap) {arrival}" + ); + for index in block + 1..block + 8 { + send.run(index * QUANTUM as u64); + } + assert_eq!( + send.route.ramps.target().map(f32::to_bits), + closed.map(f32::to_bits), + "a(tap) {arrival}: closed after the fade" + ); + } + } + + /// #1655 D4 (V4): the follow term ORs the source lane's own mute curve with the VCA mute + /// curve: own closed until 1,000, again from 3,200 to 5,000; VCA closed from 3,000 to 4,000. + /// The left column opens at 1,000 (mute length 200), closes at 3,000 (`VcaMute` 320) and opens + /// at 5,000 (mute length); the own close at 3,200 and the VCA open at 4,000 move nothing. + /// + /// Red if one curve replaces the other instead of ORing (a move at 3,200 or 4,000, or none at + /// 3,000), or if the column's events take each other's lengths. + #[test] + fn a_vca_mute_ors_with_the_source_lanes_own_mute() { + let own = crate::follow_route_tests::steps( + &[0, 1_000, 3_200, 5_000], + &[1.0, 0.0, 1.0, 0.0], + 1 << 20, + ); + let vca = crate::follow_route_tests::steps(&[0, 3_000, 4_000], &[0.0, 1.0, 0.0], 1 << 20); + let curves = Curves { + source: [Some(own), None], + vca: [vec![vca], Vec::new()], + ..Curves::default() + }; + let transform = RouteTransform { gain: 0.5, ..OPEN }; + let opened = open(OPEN, 0.5); + let closed = zeroed(OPEN, 0.5, [true, false]); + let mut send = Send::new(&curves, transform); + assert_eq!( + send.route.ramps.target().map(f32::to_bits), + closed.map(f32::to_bits), + "the bound target is closed by the own curve" + ); + let events = [ + (1_000_u64, opened, 200_u32), + (3_000, closed, VCA_MUTE), + (5_000, opened, 200), + ]; + let mut previous = send.route.ramps.ramps[0]; + for index in 0..90_u64 { + send.run(index * QUANTUM as u64); + let ramp = send.route.ramps.ramps[0]; + match events + .iter() + .find(|(at, _, _)| at / QUANTUM as u64 == index) + { + Some(&(at, target, length)) => { + assert!(aims(&ramp, target, length, 0), "the event at {at}"); + assert_eq!( + u64::from(send.route.ramps.positions[0]), + QUANTUM as u64 - at % QUANTUM as u64, + "the event at {at}: its offset" + ); + } + None => assert_eq!(ramp, previous, "block {index}: no move"), + } + previous = ramp; + assert!( + aims(&send.route.ramps.ramps[1], opened, 0, 1), + "block {index}: the right column stays open" + ); + } + } + + /// #1655 D4 with R19-3: a send's gain riding across a VCA unmute of both source lanes (1 -> 0 + /// at 1,000). The unmute retargets both columns at 1,000 over the `VcaMute` length (320) + /// toward the gain the route heard (the grid event at 960, toward 1,023); no gain grid event + /// inside the fade retargets either column, so the fade ends at exactly 1,320; there the gain + /// lands over its own length (480) on the curve at the landing's last frame (1,799). + /// + /// Red if a VCA mute cursor is not a gate of the column (the grid cuts its fade), if the held + /// move is not landed at its end, or if the fade takes the mute length. + #[test] + fn a_vca_unmute_fade_holds_the_columns_value_events_to_its_end() { + let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let curves = Curves { + gain: Some(ride.clone()), + vca: [vec![step(1_000, 1.0, 0.0)], vec![step(1_000, 1.0, 0.0)]], + ..Curves::default() + }; + let mut send = Send::new(&curves, OPEN); + let curve = |t: i64| automation::value_at(&ride, t); + let by_block = ramps_by_block(&mut send, 34); + let fade = by_block[1_000 / QUANTUM]; + let heard = open(OPEN, law(curve(1_023), 0.0)); + for column in 0..2 { + assert!( + aims(&fade[column], heard, VCA_MUTE, column), + "the fade, column {column}" + ); + } + for block in 1_000 / QUANTUM..1_320 / QUANTUM { + assert_eq!(by_block[block], fade, "block {block}: the fade runs whole"); + } + let landing = by_block[1_320 / QUANTUM]; + let landed = open(OPEN, law(curve(1_320 + 479), 0.0)); + for column in 0..2 { + assert!( + aims(&landing[column], landed, 480, column), + "the landing, column {column}" + ); + let ended = fade[column].coefficients_at(VCA_MUTE); + assert!( + super::COLUMNS[column] + .iter() + .all(|&index| landing[column].start[index].to_bits() == ended[index].to_bits()), + "the landing starts where the fade ended, column {column}" + ); + } + } + + /// #1655 D6 (V4), the carry of a VCA mute cursor. A send whose left lane a VCA mute reaches + /// (closing at 1,300, opening at 2,000) and whose right lane has its own mute and a VCA mute, + /// carried at 1,344 inside the left column's fade, renders the never-swapped twin's bits + /// whether its VCA mute tables continue (verbatim) or not (placed again at the successor's + /// node time). + /// + /// Red if a VCA mute cursor's state or heard value is lost at a carry (a second close, a + /// fade cut short, or the open at 2,000 missed), in either placement. + #[test] + fn a_carried_vca_mute_renders_the_twins_bits_continued_or_repositioned() { + let curves = || Curves { + source: [None, Some(step(1_800, 0.0, 1.0))], + vca: [ + vec![crate::follow_route_tests::steps( + &[0, 1_300, 2_000], + &[0.0, 1.0, 0.0], + 1 << 20, + )], + vec![step(2_400, 1.0, 0.0)], + ], + ..Curves::default() + }; + let transform = RouteTransform { gain: 0.5, ..OPEN }; + for continued in [true, false] { + let mut twin = Send::new(&curves(), transform); + let mut predecessor = Send::new(&curves(), transform); + for index in 0..21_u64 { + assert_eq!(twin.run(index * 64), predecessor.run(index * 64)); + } + let mut successor = Send::continuing_vca( + &curves(), + transform, + [false; GRAPH_ROUTE_ROWS], + [continued; 2], + ); + swap(&mut successor, &mut predecessor); + for index in 21..60_u64 { + assert_eq!( + successor.run(index * 64), + twin.run(index * 64), + "continued {continued}, block {index}" + ); + } + } + } + + /// #1655 D6 (V4), a moved VCA mute table: a settled send whose left lane's VCA mute table + /// changes at a swap (flat open to flat closed, and back) moves the column at the successor's + /// first sample over the `VcaMute` length; with the lane's own mute closed in both plans, the + /// same change moves nothing (OR). A VCA mute removed with no follow curve left lands over + /// the `VcaMute` length too. + /// + /// Red if a moved VCA curve at a carry lands over the mute length, lands as a step, or does + /// not land; or if a carry compares the VCA curve alone instead of the OR. + #[test] + fn a_moved_vca_mute_table_lands_over_its_own_length_at_the_first_sample() { + let transform = RouteTransform { gain: 0.5, ..OPEN }; + let opened = open(OPEN, 0.5); + let closed = zeroed(OPEN, 0.5, [true, false]); + let lane = |own: Option, vca: Option| Curves { + source: [own.map(flat), None], + vca: [vca.map(flat).into_iter().collect(), Vec::new()], + gain: None, + ..Curves::default() + }; + let cases = [ + (lane(None, Some(0.0)), lane(None, Some(1.0)), Some(closed)), + (lane(None, Some(1.0)), lane(None, Some(0.0)), Some(opened)), + (lane(Some(1.0), Some(0.0)), lane(Some(1.0), Some(1.0)), None), + ]; + for (case, (before, after, moved)) in cases.into_iter().enumerate() { + let mut predecessor = Send::new(&before, transform); + for index in 0..20_u64 { + predecessor.run(index * 64); + } + let held = predecessor.route.ramps.ramps; + let mut successor = Send::new(&after, transform); + swap(&mut successor, &mut predecessor); + successor.run(20 * 64); + let ramps = successor.route.ramps; + match moved { + Some(target) => { + assert!(aims(&ramps.ramps[0], target, VCA_MUTE, 0), "case {case}"); + assert_eq!(ramps.positions[0], 64, "case {case}: at the first sample"); + } + None => assert_eq!(ramps.ramps, held, "case {case}: the OR holds"), + } + assert_eq!(ramps.ramps[1], held[1], "case {case}: the right column"); + } + + // The VCA mute removed: the predecessor's left column closed by it, the successor's + // left column hearing only the send's `ll` ride (no follow curve). + let ll = Some(flat(0.9)); + let before = Curves { + vca: [vec![flat(1.0)], Vec::new()], + matrix: [ll.clone(), None, None, None], + ..Curves::default() + }; + let after = Curves { + matrix: [ll, None, None, None], + ..Curves::default() + }; + let mut predecessor = Send::new(&before, transform); + for index in 0..20_u64 { + predecessor.run(index * 64); + } + let mut successor = Send::new(&after, transform); + swap(&mut successor, &mut predecessor); + successor.run(20 * 64); + let ramp = successor.route.ramps.ramps[0]; + assert!( + aims(&ramp, opened, VCA_MUTE, 0), + "the removed VCA mute lands over its own length: {ramp:?}" + ); + } } diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index db8f942ba..9815de35a 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -2905,27 +2905,34 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( // program. let mute_cells = builtins.strip_mute_cells(route_source_id(route).as_str()); let route_cells = builtins.route_cells(route.id.as_str()); - let (program, cells, rows) = match (mute_cells, route_cells) { + let (mut program, cells, rows) = match (mute_cells, route_cells) { (Some((program, cells)), Some((other, rows))) if std::sync::Arc::ptr_eq(&program, &other) => { - (program, cells, rows) + (Some(program), cells, rows) } - (Some((program, cells)), None) => (program, cells, [None; graph::GRAPH_ROUTE_ROWS]), - (None, Some((program, rows))) => (program, [None; 2], rows), + (Some((program, cells)), None) => { + (Some(program), cells, [None; graph::GRAPH_ROUTE_ROWS]) + } + (None, Some((program, rows))) => (Some(program), [None; 2], rows), + // #1655 D4: a send whose source lanes only VCA mutes reach takes the VCA cells' + // program below. + (None, None) => (None, [None; 2], [None; graph::GRAPH_ROUTE_ROWS]), _ => return Err(graph_failure("host.route_control.attach")), }; // #1655 D4: a source lane's curve is its own mute cell, then the reaching automated VCA // mute cells in ascending VCA ID; `automated.lanes` names the lanes that have one. The - // route op's VCA mute cursors read `vca_mutes` (V4); until then a lane that only a VCA - // mute reaches has no own cell, and the graph source refuses its flags. + // route op ORs the VCA mute cursors into the lane's follow term at `a(tap)`. let vca_mutes = follow_vca_mute_cells( |vca| builtins.vca_cells(vca), model, route_source_id(route), - &program, + &mut program, ) .ok_or_else(|| graph_failure("host.route_control.attach"))?; + let Some(program) = program else { + return Err(graph_failure("host.route_control.attach")); + }; let curves = [0, 1].map(|lane| cells[lane].is_some() || !vca_mutes[lane].is_empty()); if curves != automated.lanes || rows.map(|cell| cell.is_some()) != automated.rows { return Err(graph_failure("host.route_control.attach")); @@ -2937,9 +2944,22 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( let continued = successor.map_or([false; graph::GRAPH_ROUTE_ROWS], |base| { controls::continued_route_rows(base.committed, model, &route.id) }); + // #1655 D6: a source lane whose own and reaching VCA mute tables continue carries its + // VCA mute cursors verbatim. + let continued_vca = successor.map_or([false; 2], |base| { + controls::continued_mute_lanes(base.committed, model, route_source_id(route)) + }); follow_cells.push(( route.id.as_str(), - (program, cells, rows, flags, offset, continued), + ( + program, + cells, + vca_mutes, + rows, + flags, + offset, + (continued, continued_vca), + ), )); } let mut artifact = GraphCompiler::compile_with_builtins(GraphBuiltinsCompileRequest { @@ -2995,7 +3015,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( let mut sources: BTreeMap<&str, _> = follow_cells.into_iter().collect(); artifact .attach_route_live_controls_following(&live_superseded, six_word, |route_id| { - let (program, cells, rows, flags, offset, continued) = + let (program, cells, vca_mutes, rows, flags, offset, (continued, continued_vca)) = sources.remove(route_id.as_str())?; let node = GraphNodeId::Route { route_id: route_id.clone(), @@ -3003,13 +3023,15 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( let source = graph::GraphFollowRouteSource::new( program, cells, + vca_mutes, rows, graph::floored_input_arrival(&arrivals, &node), follow_route_jumps, graph_compiler::route_gain_law, flags, )? - .with_continued_rows(continued); + .with_continued_rows(continued) + .with_continued_vca_mutes(continued_vca); if flags.automated_gain { source.with_gain_offset(offset) } else { @@ -4422,14 +4444,14 @@ impl SendRecord { /// each reaching VCA's automated `mute` lane (`PreparedBuiltinsSession::vca_cells`, row 1, the /// lane's own index), in ascending VCA ID (the order of [`SessionModel::vca_reach_of`]). /// `vca_cells` is the plan's `PreparedBuiltinsSession::vca_cells`. Empty lanes when no entry drives -/// a VCA `mute` row. `None` when a reaching VCA's entry has no cell in `program`, the plan's one -/// automation program: a plan that disagrees with its model. Control side; it allocates the two -/// lists. +/// a VCA `mute` row. `program` is the plan's one automation program when the caller holds it (else +/// it takes the VCA cells' own). `None` when a reaching VCA's entry has no cell in that program: a +/// plan that disagrees with its model. Control side; it allocates the two lists. pub(crate) fn follow_vca_mute_cells

( vca_cells: impl Fn(&str) -> Option<(std::sync::Arc

, builtins_compiler::VcaCells)>, model: &SessionModel, source: &session::StableId, - program: &std::sync::Arc

, + program: &mut Option>, ) -> Option<[Vec; 2]> { let mut lanes = [Vec::new(), Vec::new()]; if !model @@ -4446,8 +4468,10 @@ pub(crate) fn follow_vca_mute_cells

( continue; } let (cells_program, cells) = vca_cells(vca.as_str())?; - if !std::sync::Arc::ptr_eq(&cells_program, program) { - return None; + match program { + Some(program) if !std::sync::Arc::ptr_eq(&cells_program, program) => return None, + Some(_) => {} + None => *program = Some(cells_program), } for (lane, list) in lanes.iter_mut().enumerate() { match (driven[lane], cells[1][lane]) { @@ -5334,6 +5358,7 @@ fn follow_route_jumps(lengths: GraphJumpLengths) -> graph::GraphRouteJumpLengths ); graph::GraphRouteJumpLengths { mute: ramps.for_row(LiveRampRow::Mute), + vca_mute: ramps.for_row(LiveRampRow::VcaMute), gain: ramps.for_row(LiveRampRow::SendGain), matrix: ramps.for_row(LiveRampRow::SendMatrix), } From fe4a88b71ddd900190540fcf0ec834070d2ec6ff Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 16:23:36 +0000 Subject: [PATCH 26/36] Land a removed send curve at once and flag it conservatively (#1654, root ruling B19-R7) The owed landing is removed: a carried seven-word following route that loses a route row or its source-mute curve lands the column at once over the removed row's own length, so no landing state survives a carry, a seek or a further rebuild. Such a rebuild can cut a live send move in flight, so the control plane marks it TransitionFallback while a live send move may still ramp (send-only, conservative; TBD54 removes it). The doc text sits beside #1713's on all five surfaces. Gates on the graph, host-core (Simd8, Simd4, scalar), the C ABI and both hosts. Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 76 +++ crates/capi/include/miso_engine_v1.h | 4 +- crates/capi/src/abi.rs | 4 +- crates/capi/src/runtime/live_tests.rs | 210 +++++-- crates/control-plane/src/compile.rs | 1 + crates/control-plane/src/control.rs | 106 +++- crates/graph/src/lib.rs | 4 + crates/graph/src/runtime.rs | 588 +++++++----------- crates/host-core/src/prepare.rs | 131 +++- crates/host-core/tests/route_mute.rs | 39 +- docs/C_ABI_V1_QUALIFICATION.md | 5 +- hosts/host-web/tests/control_parity.rs | 177 ++++++ .../miso-engine-v1-audio-worklet-host.d.ts | 4 +- sdk/src/browser/shipped-host.d.ts | 4 +- 14 files changed, 901 insertions(+), 452 deletions(-) diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index f0ad5c7f7..270151249 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -851,3 +851,79 @@ Sizes (x86-64, measured): `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 1,000 -> 1,072 (the cu `FollowBlock`), `GraphFollowRouteControlBinding` 856 -> 808 (no owed slot per column table). Charges read `size_of`, so no pinned number moved. Realtime policy: 171 marked regions in 35 files (unchanged). `scripts/check-graph-policy.sh`: PASS. + +## Decision record: B19-R7 split (batch 19) + +Root ruling B19-R7 after the U2 fold 2 FAIL: the owed landing is removed and the cut it leaves is +flagged conservatively. TBD54 lands the exact case and deletes the flag. Nothing changed under +`crates/engine/src/realtime/`; no row class or table changed (no SDK regeneration). + +- **Render (`crates/graph/src/runtime.rs`).** Deleted `FollowCursors::owed`, `walk_follow`, + `column_window`, `FollowBlock::{steps, seek, frames}` and the owed resets in `apply_cell` and + `adopt`; the block entry, the probe and the mix call `walk_columns` over `block.spans` again. In + `place_carried` a carried seven-word column that lost a role (`dropped`), or that composes + otherwise with no placed value, lands at once over the removed row's own `J` (the mute `J` for a + source mute curve, the longer one with a coincident moved value, R19-4), with or without a + column ramp in flight: fold 1's settled branch for every case; the no-walk branch lands at once + too. No landing state survives a carry, so no owed length can leak (fold 2's P2/P3 property). + Fold 1's MAJOR-1, MAJOR-2 (equal branch defers to the free sample, R19-5) and MAJOR-4 + (`with_held_grid`) are unchanged. +- **Join (`crates/graph/src/lib.rs`, `crates/host-core/src/prepare.rs`).** `GraphLiveRoute` gains + `automated: RouteAutomatedSet` (a following route's bound set; the default for a six-word + route), so the join compares both plans' sets: `RouteCarry::follow_loses_a_curve` is set when a + carried route is following in both plans and the predecessor's set has a source lane or route + row the successor's lacks (`loses_a_curve`). The render's `dropped` roles are exactly these + cells, so the term never misses a dropped role. `RouteCarry::follow_retarget_ramp` is the + longest ramp of the join's seven-word records. Both reach `PlanStateInventory` + (`carries_a_following_route_that_loses_a_curve`, `following_route_retarget_ramp`). +- **Flag (`crates/control-plane/src/control.rs`, the B17-R14 pattern).** `SessionState::send_moves: + Option` folds (newest revision, longest ramp) of every live commit's seven-word + route records with a nonzero ramp, of a rebuild's join records, and, for a rebuild whose join + loses a curve, of the landing it starts (the session's longest of the fader, mute and pan + lengths). A transaction rebuild completes `TransitionFallback` when `follow_into_six_word` + (#1713) or when `follow_loses_a_curve` and the window may ramp (`watermark.revision < revision || + render_clock < watermark.first_sample + ramp`; a watermark read that gives up counts as "may"), + read before the reservation. A declared discontinuity is never flagged. +- **Over-report (documented).** The join reads route-level sets, not which column lost the curve, + and the window is one bound over every send move of the session: a removal on one send flags + while a move ramps on another send, or on the other column of the same send; a fully applied + move whose window has not yet passed `first_sample + ramp` by the render clock also flags. A + removal with no live send move in the window completes `EXACT`. +- **Doc (beside #1713's, one wording):** `crates/capi/src/abi.rs`, `crates/capi/include/ + miso_engine_v1.h`, `docs/C_ABI_V1_QUALIFICATION.md`, `hosts/host-web/web/miso-engine-v1-audio- + worklet-host.d.ts`, `sdk/src/browser/shipped-host.d.ts`: "It also reports a rebuild that carries + a send into one that no longer hears one of its stored curves while a live send move may still + ramp (conservative; TBD54 removes it)." + +Gates (all green): +- Graph: `a_removed_send_row_lands_at_once_across_a_move_in_flight` (replaces fold 1's + `a_removed_send_row_lands_after_a_move_in_flight` and fold 2's + `a_column_losing_its_last_curve_keeps_a_move_in_flight`; both cases: the column with a gain + ride and the column's last curve) and + `a_removed_send_row_leaves_no_stale_value_after_a_seek_or_a_further_rebuild` (the verifier's + probe setups, two forms x no event, a second rebuild, identity seeks at 1,920 and 3,136, forward, + back, back with a second rebuild: every column aims at the fresh plan's target; red on the fold 2 + code: form 0, identity seek at 3,136, column 0 stale). Fold 2's P2/P3 gates kept, renamed + `a_removed_row_landing_lengthens_no_later_jump` and + `a_source_mute_close_after_a_removed_row_fades_over_the_mute_length`. +- Host-core: `route_mute.rs` `a_send_column_losing_its_last_curve_lands_at_once_across_the_hand_move` + (replaces `..._keeps_the_hand_move_in_flight`; Simd8 when avx2, Simd4, scalar; the twin until + the swap, a difference inside the move from the swap, the fresh plan after + `max(move end, swap + pan)`); `prepare.rs` `the_join_flags_a_following_route_that_loses_a_curve`. +- C ABI: `removing_a_send_row_across_a_live_send_move_is_flagged_and_lands_on_the_fresh_plan_on_the_c_abi` + (replaces `a_send_column_losing_its_last_curve_keeps_the_hand_move_on_the_c_abi`): MAJOR-A, C + (a second, unrelated rebuild: `EXACT`), D (session seek inside, forward, back) flagged + `TRANSITION_FALLBACK` with count 1, the move ended long before `EXACT`; both planes equal a fresh + plan seeked to the last adoption or seek after the move's end and the landing. + `rebuild_and_adopt_as` takes the expected outcome. +- Both hosts: `removing_a_send_row_across_a_live_send_move_is_flagged_alike_on_both_hosts` (the same + six cases, one watermark, counts, both hosts bit for bit and the fresh plan after the landing). +- The fold 1 no-move gates (`removing_a_send_row_or_its_source_mute_curve_lands_*`) stay and assert + `EXACT` through `rebuild_and_adopt`. +- Red with the control-plane term forced off: the C ABI gate ("the revision's outcome at + adoption", flags 1) and the both-hosts gate (MAJOR-A outcome 1). + +Sizes (x86-64, measured): `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 1,072 -> 952; `GraphLiveRoute` 24 -> 32. +Charges read `size_of`. Realtime policy: 171 marked regions in 35 files (unchanged). +`scripts/check-graph-policy.sh`: PASS. `check-capi-abi.sh`, `check-sdk-generated.sh`, +`check-sdk-types.sh`: pass. diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index 4fc865294..fd6e3dcaf 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 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; #1713 removes it). The host + * none (conservative: it can be set when no send moves at the swap; #1713 removes it). It also + * reports a rebuild that carries a send into one that no longer hears one of its stored curves + * while a live send move may still ramp (conservative; TBD54 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 202d2a5b5..4f9b9cc75 100644 --- a/crates/capi/src/abi.rs +++ b/crates/capi/src/abi.rs @@ -95,7 +95,9 @@ 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 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; -/// #1713 removes it). +/// #1713 removes it). It also reports a rebuild that carries a send into one that no longer hears +/// one of its stored curves while a live send move may still ramp (conservative; TBD54 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 e75181fee..0fbef693b 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -10945,6 +10945,17 @@ fn ride_document(end_db: Option) -> String { /// and the source was never reseeked. Returns the adoption block's first sample `T` and the /// blocks rendered from it on, the adoption block first, `blocks` long. fn rebuild_and_adopt(rig: &mut Rig, edits: &[SessionEdit], blocks: usize) -> (u64, Vec>) { + rebuild_and_adopt_as(rig, edits, blocks, None) +} + +/// [`rebuild_and_adopt`] whose revision completes with the outcome flags `outcome` (B19-R7); +/// `None` is `EXACT` with no transition fallback or supersession on the plan. +fn rebuild_and_adopt_as( + rig: &mut Rig, + edits: &[SessionEdit], + blocks: usize, + outcome: Option, +) -> (u64, Vec>) { let bytes = rig.transaction(edits); let (result, response) = rig.send(&bytes); assert_eq!(result, crate::RESULT_OK); @@ -10955,11 +10966,22 @@ fn rebuild_and_adopt(rig: &mut Rig, edits: &[SessionEdit], blocks: usize) -> (u6 rendered.push((rig.block, rig.step())); assert!(rendered.len() < 16, "the candidate is adopted"); } - let (applied, first, flags, _) = watermark(rig); + let (applied, first, flags) = if outcome.is_none() { + let (applied, first, flags, _) = watermark(rig); + (applied, first, flags) + } else { + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + ( + watermark.revision, + watermark.first_sample, + watermark.outcome_flags, + ) + }; assert_eq!( (applied, flags), - (revision, crate::OUTCOME_EXACT), - "the revision completes exactly at adoption" + (revision, outcome.unwrap_or(crate::OUTCOME_EXACT)), + "the revision's outcome at adoption" ); let adoption = first / rig.quantum as u64; let mut from: Vec> = rendered @@ -12139,19 +12161,24 @@ fn removing_a_send_row_or_its_source_mute_curve_lands_on_the_fresh_plan_on_the_c } } -/// #1654 U2 fold 2, MAJOR-A on the C ABI. The send `eq0-b` hears flat `ll` and `rr` entries at +/// #1654 root ruling B19-R7 on the C ABI. The send `eq0-b` hears flat `ll` and `rr` entries at /// 0.5; a hand gain move to -6 dB over 1,000 samples is in flight when a rebuild removes the -/// `ll` entry, so the send's left source column no longer hears a curve. The rebuild completes -/// `EXACT`, with no transition fallback; until the move's end every sample is a twin's that took -/// the same move and no rebuild (the move keeps its length on both columns); after the column's -/// landing over `SendMatrix` (240) the plan renders a fresh plan of the committed document. +/// `ll` entry, so the send's left source column loses its last curve and lands at once over +/// `SendMatrix` (240), cutting the move there. The rebuild completes `TRANSITION_FALLBACK` +/// (count 1). Then: nothing (MAJOR-A); a second rebuild that edits only the direct route +/// (MAJOR-C: `EXACT`, it loses no curve); or a session seek inside the move, forward or back +/// (MAJOR-D). From the first block boundary after the move's end and the landing, both planes +/// are a fresh plan of the committed document, session-seeked to the last adoption or seek. The +/// same removal with the move ended long before completes `EXACT` and lands the same way. /// -/// Test value: red if a column that loses its last curve retargets at the swap and cuts the -/// live move in flight. +/// Test value: red if the rebuild completes `EXACT` while the cut move ramps (or is flagged with +/// no move), or if a removed row's value survives the landing, a seek or a further rebuild. #[test] -fn a_send_column_losing_its_last_curve_keeps_the_hand_move_on_the_c_abi() { +fn removing_a_send_row_across_a_live_send_move_is_flagged_and_lands_on_the_fresh_plan_on_the_c_abi() +{ use session::ParameterUnit::Linear; - const RAMP: u32 = 1_000; + const RAMP: u64 = 1_000; + const BLOCKS: usize = 48; let document = send_rows_document( vec![ route_entry("send-ll", "eq0-b", 3, send_flat(0.5, Linear, false)), @@ -12160,61 +12187,122 @@ fn a_send_column_losing_its_last_curve_keeps_the_hand_move_on_the_c_abi() { |_| {}, ); let quantum = 128_usize; - let edit = send_row_edit("gain_db", -6.0, Some(RAMP)); - let mut rig = Rig::new(&document); - let mut twin = Rig::new(&document); - for _ in 0..4 { - assert_eq!(bits(&rig.step()), bits(&twin.step())); - } - let (start, _) = live_and_render(&mut rig, std::slice::from_ref(&edit), 2); - let _ = live_and_render(&mut twin, std::slice::from_ref(&edit), 2); - let (adoption, live) = rebuild_and_adopt( - &mut rig, - &[SessionEdit::RemoveAutomation { - automation_id: StableId::parse("send-ll").expect("automation ID"), - }], - 48, - ); - let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); - assert_eq!(result, crate::RESULT_OK); - assert_eq!( - watermark.transition_fallback_count, 0, - "no transition fallback" - ); - while (twin.block as usize) * quantum < adoption as usize { - twin.step(); - } - let reference: Vec<_> = (0..live.len()).map(|_| twin.step()).collect(); - let end = (start + u64::from(RAMP) - adoption) as usize; - assert!(end > 0, "the move is in flight at the adoption"); - for plane in 0..2 { - let (live, twin) = ( - plane_bits(&live, plane, quantum), - plane_bits(&reference, plane, quantum), + let fallback = crate::OUTCOME_TRANSITION_FALLBACK; + let remove = [SessionEdit::RemoveAutomation { + automation_id: StableId::parse("send-ll").expect("automation ID"), + }]; + let move_edit = send_row_edit("gain_db", -6.0, Some(RAMP as u32)); + // (label, the move's lead before the rebuild in blocks, then) + #[derive(Clone, Copy, Debug)] + enum Then { + Nothing, + Rebuild, + Seek(i64), + } + for (label, lead, then) in [ + ("MAJOR-A", 2, Then::Nothing), + ("MAJOR-C", 2, Then::Rebuild), + ("MAJOR-D inside", 2, Then::Seek(0)), + ("MAJOR-D forward", 2, Then::Seek(5_000)), + ("MAJOR-D back", 2, Then::Seek(-1)), + ("the move ended", 24, Then::Nothing), + ] { + let mut rig = Rig::new(&document); + for _ in 0..4 { + rig.step(); + } + let (start, _) = live_and_render(&mut rig, std::slice::from_ref(&move_edit), lead); + let ended = lead * quantum > RAMP as usize + 2 * quantum; + // A case that continues renders only the adoption block here. + let blocks = if matches!(then, Then::Nothing) { + BLOCKS + } else { + 1 + }; + let (adoption, mut live) = rebuild_and_adopt_as( + &mut rig, + &remove, + blocks, + Some(if ended { + crate::OUTCOME_EXACT + } else { + fallback + }), ); + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); assert_eq!( - live[..end], - twin[..end], - "plane {plane}: the move runs to its end" - ); - } - let fresh = seeked_reference(&rig.snapshot(), adoption, 48); - let landed = (end + 240).div_ceil(quantum) * quantum; - let mut lands = false; - for plane in 0..2 { - let (send, fresh, twin) = ( - plane_bits(&live, plane, quantum), - plane_bits(&fresh, plane, quantum), - plane_bits(&reference, plane, quantum), + watermark.transition_fallback_count, + u64::from(!ended), + "{label}: the fallback count" ); - lands |= send[end..landed] != twin[end..landed]; assert_eq!( - send[landed..], - fresh[landed..], - "plane {plane}: a fresh plan after the landing" + rig.current_follow_route_cells().len(), + 1, + "{label}: seven-word" ); + let end = (start + RAMP).saturating_sub(adoption) as usize; + assert_eq!( + end > 0, + !ended, + "{label}: the move is in flight at the adoption" + ); + // The sample the reference starts at (render and timeline), and the blocks from it. + let (from, timeline) = match then { + Then::Nothing => (adoption, adoption), + Then::Rebuild => { + let mut direct = rig + .model() + .routes + .into_iter() + .find(|route| route.id.as_str() == "eq0-main") + .expect("the direct route"); + direct.gain_db = -3.0; + let (second, after) = rebuild_and_adopt_as( + &mut rig, + &[SessionEdit::UpsertRoute { route: direct }], + BLOCKS, + Some(crate::OUTCOME_EXACT), + ); + assert!(second < start + RAMP, "{label}: the move still runs"); + live = after; + (second, second) + } + Then::Seek(target) => { + let at = rig.block * quantum as u64; + assert!(at < start + RAMP, "{label}: the seek is inside the move"); + let timeline = match target { + 0 => at, + -1 => 256, + forward => forward as u64, + }; + rig.generation += 1; + assert_eq!( + crate::ffi::test_session_seek(rig.session, rig.generation, timeline, None), + crate::RESULT_OK + ); + rig.fed = timeline; + live = (0..BLOCKS).map(|_| rig.step()).collect(); + (at, timeline) + } + }; + let fresh = seeked_reference(&rig.snapshot(), timeline, BLOCKS); + let landed = ((start + RAMP).saturating_sub(from) as usize) + .max(adoption.saturating_sub(from) as usize + 240) + .div_ceil(quantum) + * quantum; + for plane in 0..2 { + let (send, fresh) = ( + plane_bits(&live, plane, quantum), + plane_bits(&fresh, plane, quantum), + ); + assert_eq!( + send[landed..], + fresh[landed..], + "{label}, plane {plane}: a fresh plan after the landing" + ); + } } - assert!(lands, "the column lands after the move"); } /// #1654 R19-5 on the C ABI. On [`send_ride_document`], a live send move with its own 4,000 diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index 722185aba..ff0d747eb 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -1480,6 +1480,7 @@ where observation_armed_bytes: 0, observation_slots_high_water: 0, observation_forwarded: Vec::new(), + send_moves: None, }; let (offsets, pending, counts, armed, armed_bytes) = crate::control::observation_slots( &session.providers.effects, diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 26cba3d71..2a8ae80da 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -646,6 +646,59 @@ pub struct SessionState { /// from the candidate it superseded (#1310, root ruling B15-Q4); empty after a rebuild that /// superseded nothing. pub(crate) observation_forwarded: Vec, + /// The live send moves that may still ramp, as one conservative window (root ruling B19-R7, + /// the B17-R14 pattern; TBD54 deletes it): `None` when none may. + pub(crate) send_moves: Option, +} + +/// Every live send move (a following route's record with a ramp, written by a live commit or by a +/// rebuild's send join) and every landing a rebuild's removed send row starts, folded into one +/// bound (B19-R7): the newest revision among them and the longest ramp any of them takes. A move +/// is in effect from at most the watermark's `first_sample` once the watermark covers its revision +/// (the watermark is monotone), so every move has ended once the watermark covers `revision` and +/// the render clock is at or past `first_sample + ramp`. The bound only errs toward "may still +/// ramp", and it is one word pair: nothing grows with the moves. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct SendMoveWindow { + revision: u64, + ramp: u32, +} + +impl SendMoveWindow { + /// Whether some move of the window may still ramp, given the watermark (`None` when a read + /// gave up: then it may) and the render clock read after it. + fn may_ramp(self, watermark: Option, render_clock: u64) -> bool { + watermark.is_none_or(|watermark| { + watermark.revision < self.revision + || render_clock < watermark.first_sample.saturating_add(u64::from(self.ramp)) + }) + } +} + +/// Fold a send move of `revision` over `ramp` into the window (B19-R7): the newest revision and +/// the longest ramp. A zero ramp is a step, which nothing can cut, and folds nothing. +fn fold_send_move(window: &mut Option, revision: u64, ramp: u32) { + if ramp == 0 { + return; + } + *window = Some(match *window { + Some(window) => SendMoveWindow { + revision: window.revision.max(revision), + ramp: window.ramp.max(ramp), + }, + None => SendMoveWindow { revision, ramp }, + }); +} + +/// The longest length a removed send row's landing takes at its successor's first block (B19-R7): +/// a following route's `SendGain`, `SendMatrix` and mute lengths are the session's fader, pan and +/// mute lengths, each at most the route ramp bound. +fn send_landing_ramp(model: &session::SessionModel) -> u32 { + let ramps = controls::LiveRamps::for_session(model); + ramps + .fader_samples + .max(ramps.mute_samples) + .max(ramps.pan_samples) } /// The stable key of one observation tap, borrowed from the bytes that name it (#1677): the strip's @@ -2455,6 +2508,9 @@ where // #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(); + // B19-R7 (TBD54 deletes it): the same, for a carried following route that loses a curve. + let follow_loses_a_curve = inventory.carries_a_following_route_that_loses_a_curve(); + let follow_retarget_ramp = inventory.following_route_retarget_ramp(); let mut candidate_provider = ProviderEpoch::candidate( sources, inventory, @@ -2543,7 +2599,39 @@ where // 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. - let outcome = if matches!(kind, Rebuild::Transaction(_)) && follow_into_six_word { + // + // B19-R7 (TBD54 deletes this): a successor whose send join carries a following route + // into a following route that no longer hears one of its row or source mute curves lands + // that column at once over the removed row's length, cutting a live send move still + // ramping there, which render cannot report. Control cannot tell which column lost the + // curve or whether its move is in flight, so such a rebuild completes as the transition + // fallback whenever any live send move may still ramp (read here, before the + // reservation; a watermark read that gives up counts as "may"). A declared + // discontinuity carries nothing. Conservative, it over-reports. + let transaction = matches!(kind, Rebuild::Transaction(_)); + let send_fallback = transaction + && follow_loses_a_curve + && self.send_moves.is_some_and(|window| { + window.may_ramp( + self.shared.watermark.read().ok(), + self.publisher.render_clock(), + ) + }); + // The rebuild's own send moves join the window with the commit: its join's records, and + // a removed row's landing. + let rebuild_send_ramp = match &kind { + Rebuild::Transaction(prepared) if follow_loses_a_curve => { + let session = prepared.get().prospective_session(); + let next = session + .compiled() + .ok_or(CommandError::Internal)? + .normalized_model(); + follow_retarget_ramp.max(send_landing_ramp(next)) + } + Rebuild::Transaction(_) => follow_retarget_ramp, + Rebuild::Discontinuity(_) => 0, + }; + let outcome = if transaction && (follow_into_six_word || send_fallback) { engine::realtime::CandidateOutcome::TransitionFallback } else { engine::realtime::CandidateOutcome::Exact @@ -2738,6 +2826,8 @@ where // withdrawn candidate, drops below as a commit's displaced model does. Rebuild::Discontinuity(model) => (None, Some(*model)), }; + // B19-R7: the rebuild's own send moves ramp from its successor's first block. + fold_send_move(&mut self.send_moves, revision, rebuild_send_ramp); // #1559 D8: the overlay is written after the last fallible step, with the commit. let monitor_ramp = writes_monitor.then_some(monitor_change.ramp); let previous_monitor = core::mem::replace(&mut self.monitor, monitor_change.overlay); @@ -3385,6 +3475,19 @@ where } else { protocol::EditPath::Live }; + // B19-R7: the longest ramp of the following routes' records this commit writes, folded + // into the send-move window once committed. + let send_ramp = delta + .routes + .iter() + .filter_map(|route| { + route + .record + .following() + .map(host_core::FollowRouteControlRecord::length) + }) + .max() + .unwrap_or(0); drop(delta); let published = core::mem::take(&mut owners.begun); @@ -3400,6 +3503,7 @@ where ); // #1559 D8: the overlay is written after the last fallible step, with the commit. self.monitor = monitor_change.overlay; + fold_send_move(&mut self.send_moves, revision, send_ramp); for index in published { owners.effects[index] .commit_owner() diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 47d552dea..b0e1a30d1 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -5159,6 +5159,10 @@ pub struct GraphLiveRoute { /// Whether the route is a following route (#1652 D1): a successor's move from this row /// carries [`GraphRouteMove::predecessor_follows`]. pub following: bool, + /// A following route's automated set, the curves its cursors hear (#1654 D2); the default + /// (nothing) for a six-word route. A successor's join compares it with its own to find a + /// carried route that loses a row or a source mute curve (B19-R7). + pub automated: RouteAutomatedSet, } impl GraphLiveRoute { diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index 094a60081..f0c637801 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -1649,12 +1649,6 @@ struct FollowCursors { /// Per column, the roles the predecessor's column heard and this plan's does not (a removed /// row or source mute curve), set by a carry and read by its first block (U2 fold 1, MAJOR-3). dropped: [[bool; ROLES]; 2], - /// Per column, a landing a carry owes it (U2 fold 2): at node time `free`, the first free - /// sample of the column ramp in flight at the swap, the column retargets to its composition - /// over `length` (a removed row's own length). It is the column's, never a cursor's; any - /// event or record that composes the column first spends it, and a seek reaching the block - /// at or before `free` drops it (its landing composes). - owed: [Option<(i64, u32)>; 2], /// How the route's next block begins: fresh, carried or running. start: FollowStart, } @@ -1679,11 +1673,6 @@ enum FollowStart { #[derive(Clone, Copy)] struct FollowBlock { spans: [Option; 2], - /// Each column span's step (`(offset, node time)`), to cut it at an owed landing. - steps: [Option<(u32, i64)>; 2], - /// The graph's seek step into the block, if any. - seek: Option<(u32, i64)>, - frames: u32, lengths: crate::GraphRouteJumpLengths, start: Option, } @@ -1691,9 +1680,6 @@ struct FollowBlock { impl FollowBlock { const IDLE: Self = Self { spans: [None; 2], - steps: [None; 2], - seek: None, - frames: 0, lengths: crate::GraphRouteJumpLengths { mute: 0, gain: 0, @@ -1743,7 +1729,6 @@ impl LiveFollowRoute { gain_law: source.gain_law, continued: source.continued, dropped: [[false; ROLES]; 2], - owed: [None; 2], start: FollowStart::Fresh, }; let composition = Composition { @@ -1952,25 +1937,23 @@ impl FollowCursors { return (idle, start); } let program = &self.program; - let seek = span.step.map(|step| (step.offset, step.timeline)); - let steps = [0, 1].map(|column| { + let spans = [0, 1].map(|column| { let walk = self.columns.get(column).and_then(Option::as_ref)?; - seek.or_else(|| { - if start != FollowStart::Fresh { - return None; - } - let differs = walk.walk.cursors().iter().any(|cursor| { - program.cell(cursor.cell() as usize).is_some_and(|cell| { - let value = automation::value_at(cell.segments(), span.start); - cursor.state().target().map(f32::to_bits) != Some(value.to_bits()) - }) + let step = span + .step + .map(|step| (step.offset, step.timeline)) + .or_else(|| { + if start != FollowStart::Fresh { + return None; + } + let differs = walk.walk.cursors().iter().any(|cursor| { + program.cell(cursor.cell() as usize).is_some_and(|cell| { + let value = automation::value_at(cell.segments(), span.start); + cursor.state().target().map(f32::to_bits) != Some(value.to_bits()) + }) + }); + differs.then_some((0, span.start)) }); - differs.then_some((0, span.start)) - }) - }); - let spans = [0, 1].map(|column| { - self.columns.get(column).and_then(Option::as_ref)?; - let step = steps.get(column).copied().flatten(); // The graph's step lies inside the block, so the span is valid; a refused one leaves // the column holding its curves, with no panic. automation::NodeSpan::new(span.start, step, frames).ok() @@ -1978,9 +1961,6 @@ impl FollowCursors { ( FollowBlock { spans, - steps, - seek, - frames, lengths, start: Some(span.start), }, @@ -2025,192 +2005,6 @@ fn walk_columns( } } -/// The offsets `[from, to)` of a column span (offset 0 at node time `start`, seek `step`, -/// `frames` long) as a span of their own, as the automation crate's span window: the same node -/// times, and the step when it lies inside. `None` for an empty or out-of-block window. -fn column_window( - start: i64, - step: Option<(u32, i64)>, - frames: u32, - from: u32, - to: u32, -) -> Option { - if from >= to || to > frames { - return None; - } - let (start, step) = match step { - Some((step, time)) if from > step => (time.checked_add(i64::from(from - step))?, None), - Some((step, time)) if step < to => ( - start.checked_add(i64::from(from))?, - Some((step - from, time)), - ), - _ => (start.checked_add(i64::from(from))?, None), - }; - automation::NodeSpan::new(start, step, to - from).ok() -} - -/// [`walk_columns`] with the columns' owed landings (U2 fold 2): a column that owes a landing -/// at offset `o` of this block has its walks cut at `o` and `o + 1` (a cell's events over a -/// window are its block's events there, so the cuts move no event), and at `o` it lands on its -/// composition (the heard values; its cell's words with no curve) over the owed length, an event -/// passed to `change` as any other. A coincident event of its own takes the longer ramp (R19-4). -/// A column with a walk takes the landing as a column jump ([`automation::Composed::retarget`] -/// between its cut walks), so its value grid holds inside it (R19-3). An event of the column -/// before `o` composed it and spends the landing; a seek at or before `o` drops it (its landing -/// composes). A landing that fired or was spent leaves `owed` as `None`. -fn walk_follow( - program: &automation::Program, - mut columns: [Option<(&mut automation::Composed, ColumnRoles)>; 2], - block: &FollowBlock, - owed: &mut [Option<(i64, u32)>; 2], - composition: Composition<'_>, - mut change: impl FnMut(usize, automation::ComposedEvent), -) { - let frames = block.frames; - let Some(start) = block.start else { - walk_columns(program, columns, block.spans, composition, change); - return; - }; - // Each owed column's landing offset, when it lands in this block. - let mut pending = [None::; 2]; - for (slot, owed) in pending.iter_mut().zip(owed.iter_mut()) { - let Some((free, _)) = *owed else { - continue; - }; - let offset = u32::try_from(free.saturating_sub(start).max(0)).unwrap_or(u32::MAX); - if offset >= frames { - continue; - } - if block.seek.is_some_and(|(seek, _)| seek <= offset) { - *owed = None; - continue; - } - *slot = Some(offset); - } - let [first, second] = pending; - if first.is_none() && second.is_none() { - walk_columns(program, columns, block.spans, composition, change); - return; - } - let cut = |offset: Option, after: u32| { - offset.map_or(frames, |offset| { - core::cmp::min(offset.saturating_add(after), frames) - }) - }; - let mut cuts = [ - 0, - cut(first, 0), - cut(first, 1), - cut(second, 0), - cut(second, 1), - frames, - ]; - cuts.sort_unstable(); - for (&from, &to) in cuts.iter().zip(cuts.iter().skip(1)) { - if from >= to { - continue; - } - let spans = [0, 1].map(|column| { - block.spans.get(column).copied().flatten()?; - let step = block.steps.get(column).copied().flatten(); - column_window(start, step, frames, from, to) - }); - // Per landing column: whether its own event at `o` took the landing, and the target of - // one whose ramp the landing lengthened. - let mut fired = [false; 2]; - let mut lengthened = [None::; 2]; - { - let [zero, one] = &mut columns; - walk_columns( - program, - [ - zero.as_mut().map(|(walk, roles)| (&mut **walk, *roles)), - one.as_mut().map(|(walk, roles)| (&mut **walk, *roles)), - ], - spans, - composition, - |column, mut event| { - event.offset = event.offset.saturating_add(from); - if let (Some(Some(offset)), false) = (pending.get(column).copied(), event.held) - { - let length = owed.get(column).copied().flatten().map_or(0, |(_, n)| n); - if event.offset < offset { - // The column composed before its landing: the debt is spent. - if let Some(slot) = pending.get_mut(column) { - *slot = None; - } - if let Some(slot) = owed.get_mut(column) { - *slot = None; - } - } else if event.offset == offset { - if let Some(slot) = fired.get_mut(column) { - *slot = true; - } - if length > event.ramp { - event.ramp = length; - event.kind = automation::EventKind::Jump; - if let Some(slot) = lengthened.get_mut(column) { - *slot = Some(event.value); - } - } - } - } - change(column, event); - }, - ); - } - for column in 0..COLUMNS.len() { - if pending.get(column).copied().flatten() != Some(from) { - continue; - } - let length = owed.get(column).copied().flatten().map_or(0, |(_, n)| n); - let time = start.saturating_add(i64::from(from)); - let walk = columns.get_mut(column).and_then(Option::as_mut); - if fired.get(column).copied().unwrap_or(false) { - if let (Some((walk, _)), Some(Some(target))) = - (walk, lengthened.get(column).copied()) - { - walk.retarget(target, time, length); - } - } else { - let landing = |value| automation::ComposedEvent { - offset: from, - value, - ramp: length, - kind: automation::EventKind::Jump, - held: false, - }; - match walk { - Some((walk, roles)) => { - let roles = *roles; - let target = walk.compose_heard(|heard| { - column_target(column, roles, composition, |index| heard.value(index)) - }); - if !walk - .target() - .is_some_and(|held| automation::Composable::same_bits(&held, &target)) - { - walk.retarget(target, time, length); - change(column, landing(target)); - } - } - None => { - let target = - column_target(column, ColumnRoles::default(), composition, |_| 0.0); - change(column, landing(target)); - } - } - } - if let Some(slot) = pending.get_mut(column) { - *slot = None; - } - if let Some(slot) = owed.get_mut(column) { - *slot = None; - } - } - } -} - /// Column `index`'s next event, if it walks this block. fn next_column_event( index: usize, @@ -2338,8 +2132,6 @@ impl LiveFollowRoute { } } } - // The record composed every column: an owed landing is spent (U2 fold 2). - follow.owed = [None; 2]; *mute = follow.silent[0] && follow.silent[1]; applied = 1; } @@ -2360,7 +2152,6 @@ impl LiveFollowRoute { self.ramps = ramps; self.mute = mute; self.follow.silent = from.map_or([mute; 2], |from| from.follow.silent); - self.follow.owed = [None; 2]; let continued = self.follow.continued; for (column, (walk, dropped)) in self .follow @@ -2433,13 +2224,12 @@ impl LiveFollowRoute { /// ramp's first free sample, at once when it is settled (#1711 D1), where each one whose curve /// moved jumps over its own row's length. A column that no longer hears a row or source mute /// curve of its predecessor and composes otherwise (U2 fold 1, MAJOR-3: no cursor's curve - /// carries that move) lands over the removed row's own length (the mute length for a mute - /// curve, the longer one with a coincident moved value, R19-4): at once when settled, a column - /// jump; else at the column ramp's first free sample, a landing the column owes - /// (`FollowCursors::owed`, U2 fold 2: the column's, never a cursor's; [`walk_follow`] lands - /// it). A column with no curve compares its cell's target over the mute length, at once; one - /// that lost a row lands over that row's length, at once when settled, else owed at the - /// column ramp's first free sample (U2 fold 2, MAJOR-A). + /// carries that move) lands at once over the removed row's own length (the mute length for a + /// mute curve, the longer one with a coincident moved value, R19-4), a column jump, also with + /// a column ramp in flight, which it cuts (B19-R7: the control plane marks such a rebuild + /// `TransitionFallback` while a live send move may still ramp; TBD54 lands it exactly). No + /// landing state survives the carry. A column with no curve compares its cell's target over + /// the mute length, at once (over the removed row's length when it lost one). fn place_carried(&mut self, at: i64) { let Self { ramps, @@ -2478,14 +2268,6 @@ impl LiveFollowRoute { let Some(walk) = follow.columns.get_mut(column).and_then(Option::as_mut) else { let target = column_target(column, ColumnRoles::default(), composition, |_| 0.0); let length = if has_dropped { dropped } else { jump }; - if has_dropped && remaining > 0 && !same_entries(adopted, target.0) { - // U2 fold 2, MAJOR-A: the move in flight keeps its length; the column's - // landing is owed at its end. - if let Some(slot) = follow.owed.get_mut(column) { - *slot = Some((free, length)); - } - continue; - } ramps.retarget_column( column, with_column(ramps.target(), column, target.0), @@ -2564,11 +2346,11 @@ impl LiveFollowRoute { if let Some(silent) = follow.silent.get_mut(column) { *silent = target.1; } - } else if (has_dropped || !any_placed) && remaining == 0 { + } else if has_dropped || !any_placed { // MAJOR-3: the column moved with no cursor whose curve moved (a removed row or - // source mute curve): settled, it lands at once over the removed row's length, a - // column jump (R19-3). - // A placed value whose curve moved lands in it, so it owes no landing of its own. + // source mute curve): it lands at once over the removed row's length, a column + // jump (R19-3), cutting a move in flight (B19-R7, flagged by the control plane). + // A placed value whose curve moved lands in it, with the longer length (R19-4). let length = core::cmp::max(if has_dropped { dropped } else { jump }, moved); for_placed_values(walk, |cursor| { let heard = cursor.heard(); @@ -2587,13 +2369,6 @@ impl LiveFollowRoute { let silent = follow.silent.get(column).copied().unwrap_or(false); walk.walk.seed_target((adopted, silent)); for_placed_values(walk, |cursor| cursor.state_mut().defer_jump(free)); - if (has_dropped || !any_placed) - && let Some(slot) = follow.owed.get_mut(column) - { - // MAJOR-3 with a move in flight: the column's landing is owed at its end - // (U2 fold 2: held per column, not on a cursor). - *slot = Some((free, if has_dropped { dropped } else { jump })); - } } } *mute = follow.silent[0] && follow.silent[1]; @@ -2647,7 +2422,6 @@ impl LiveFollowRoute { columns, silent: heard_silent, gain_law, - owed: follow_owed, .. } = follow; let composition = Composition { @@ -2661,16 +2435,14 @@ impl LiveFollowRoute { let mut target = ramps.target().map(f32::to_bits); let mut silent = *heard_silent; let mut from = None; - let mut owed = *follow_owed; let [zero, one] = columns; - walk_follow( + walk_columns( program, [ zero.as_mut().map(|walk| (&mut walk.probe, walk.roles)), one.as_mut().map(|walk| (&mut walk.probe, walk.roles)), ], - block, - &mut owed, + block.spans, composition, |column, event| { if event.held { @@ -2696,7 +2468,6 @@ impl LiveFollowRoute { let _copied = column.walk.copy_from(&column.probe); } *heard_silent = silent; - *follow_owed = owed; } from } @@ -2723,7 +2494,6 @@ impl LiveFollowRoute { columns, silent, gain_law, - owed, .. } = follow; let composition = Composition { @@ -2732,14 +2502,13 @@ impl LiveFollowRoute { law: *gain_law, }; let [zero, one] = columns; - walk_follow( + walk_columns( program, [ zero.as_mut().map(|walk| (&mut walk.walk, walk.roles)), one.as_mut().map(|walk| (&mut walk.walk, walk.roles)), ], - block, - owed, + block.spans, composition, |column, event| { if event.held { @@ -9610,9 +9379,11 @@ 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, following) = match &op.kind { - NodeKind::LiveRoute(live) => (live.route, false), - NodeKind::LiveFollowRoute(live) => (live.route, true), + let (route, following, automated) = match &op.kind { + NodeKind::LiveRoute(live) => { + (live.route, false, crate::RouteAutomatedSet::default()) + } + NodeKind::LiveFollowRoute(live) => (live.route, true, live.flags.automated_set()), _ => return None, }; let GraphNodeId::Route { route_id } = &route_gates.get(route as usize)?.0 else { @@ -9622,6 +9393,7 @@ fn live_route_rows( route_id: Box::from(route_id.as_str()), unit: u32::try_from(unit).expect("a unit index fits the op index width"), following, + automated, }) }) .collect() @@ -20707,52 +20479,185 @@ mod send_automation_tests { } } - /// U2 fold 1, MAJOR-3 with a live move in flight: the `ll` row is removed at a swap (1,280) - /// while a hand offset (+3 dB over 2,000 from 1,152) runs. The move keeps its length; at its - /// end (3,152) the column lands on the static `ll` over `SendMatrix` (960). + /// B19-R7 (a removed row with a live move in flight): the `ll` row is removed at a swap + /// (1,280) while a hand offset (+3 dB over 2,000 from 1,152) runs on both columns. Column 0 + /// lands at once on the static `ll` over `SendMatrix` (960), cutting the move there (the + /// control plane flags that rebuild `TransitionFallback`); column 1, which lost nothing, keeps + /// its move. The second case removes the column's last curve (`ll`, `rr` kept): it lands the + /// same way on its cell's words. /// - /// Red if the removal never lands, or cuts the move. + /// Red if the removal never lands, or lands late (an owed landing), or touches column 1. #[test] - fn a_removed_send_row_lands_after_a_move_in_flight() { - let before = Curves { - gain: Some(flat(-6.0)), - matrix: [Some(flat(0.5)), None, None, None], - ..Curves::default() - }; - let after = Curves { - gain: Some(flat(-6.0)), - ..Curves::default() - }; - let moved = RouteTransform { gain: 3.0, ..OPEN }; - let mut predecessor = Send::new(&before, OPEN); - for index in 0..18_u64 { - predecessor.run(index * 64); - } - predecessor.write(moved, 2_000); - for index in 18..20_u64 { - predecessor.run(index * 64); - } - let record = predecessor.route.ramps.ramps; - let mut successor = - Send::continuing(&after, moved, [true, false, false, false, false, false]); - swap(&mut successor, &mut predecessor); - for index in 20..49_u64 { - successor.run(index * 64); + fn a_removed_send_row_lands_at_once_across_a_move_in_flight() { + let cases = [ + ( + Curves { + gain: Some(flat(-6.0)), + matrix: [Some(flat(0.5)), None, None, None], + ..Curves::default() + }, + Curves { + gain: Some(flat(-6.0)), + ..Curves::default() + }, + OPEN, + RouteTransform { gain: 3.0, ..OPEN }, + [true, false, false, false, false, false], + open(OPEN, law(-6.0, 3.0)), + ), + ( + Curves { + matrix: [Some(flat(0.5)), None, None, Some(flat(0.5))], + ..Curves::default() + }, + Curves { + matrix: [None, None, None, Some(flat(0.5))], + ..Curves::default() + }, + RouteTransform { gain: 1.0, ..OPEN }, + RouteTransform { gain: 0.5, ..OPEN }, + [false, false, false, false, false, true], + open(OPEN, 0.5), + ), + ]; + for (case, (before, after, base, moved, continued, landed)) in cases.iter().enumerate() { + let mut predecessor = Send::new(before, *base); + for index in 0..18_u64 { + predecessor.run(index * 64); + } + predecessor.write(*moved, 2_000); + for index in 18..20_u64 { + predecessor.run(index * 64); + } + let record = predecessor.route.ramps.ramps; assert_eq!( - successor.route.ramps.ramps, record, - "block {index}: the move runs" + record[0].length, 2_000, + "case {case}: the move is in flight" + ); + let mut successor = Send::continuing(after, *moved, *continued); + swap(&mut successor, &mut predecessor); + successor.run(20 * 64); + let ramp = successor.route.ramps.ramps[0]; + assert!( + aims(&ramp, *landed, 960, 0), + "case {case}: {:?} over {}", + ramp.target, + ramp.length ); + assert_eq!(successor.route.ramps.positions[0], 64, "case {case}"); + for index in 21..49_u64 { + successor.run(index * 64); + assert_eq!( + successor.route.ramps.ramps[1], record[1], + "case {case}, block {index}: column 1's move runs on" + ); + assert_eq!( + successor.route.ramps.ramps[0], ramp, + "case {case}, block {index}" + ); + } + } + } + + /// One block of [`QUANTUM`] samples from render sample `first` read through `driver`. + fn run_seeking(send: &mut Send, first: u64, driver: &Seeking) { + let (mut left, mut right) = input(QUANTUM); + let time = GraphBlockTime::new(Some(first), QUANTUM as u32, Some(LENGTHS), Some(driver)); + if send + .route + .begin_block(send.gate.snapshot(), &time) + .is_some() + { + send.route.mix(&mut left, &mut right); + } + } + + /// B19-R7 (no stale value survives): the setups of the batch 19 verifier's probes. A send + /// loses `ll` at a swap (1,280) with a hand move in flight, in two forms (the column's last + /// curve, `rr` kept; `ll` beside a riding gain). Then either nothing, a further rebuild of + /// the same plan at 1,600, or a seek (identity at 1,920 and at 3,136, forward 1,920 -> 5,000, + /// back 1,920 -> 256). At block 120 every column aims at the fresh plan's target (a plan that + /// never heard `ll`, bound with the moved values, through the same timeline), bit for bit. + /// + /// Red if a landing is dropped by a seek or by the second rebuild and leaves the removed + /// row's value. + #[test] + fn a_removed_send_row_leaves_no_stale_value_after_a_seek_or_a_further_rebuild() { + let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let forms = [ + ( + Curves { + matrix: [Some(flat(0.5)), None, None, Some(flat(0.5))], + ..Curves::default() + }, + Curves { + matrix: [None, None, None, Some(flat(0.5))], + ..Curves::default() + }, + RouteTransform { gain: 1.0, ..OPEN }, + RouteTransform { gain: 0.5, ..OPEN }, + [false, false, false, false, false, true], + ), + ( + Curves { + gain: Some(ride.clone()), + matrix: [Some(flat(0.5)), None, None, None], + ..Curves::default() + }, + Curves { + gain: Some(ride), + ..Curves::default() + }, + OPEN, + RouteTransform { gain: 3.0, ..OPEN }, + [true, false, false, false, false, false], + ), + ]; + // (seek at, seek to, a second rebuild at block 25) + let scenarios = [ + (u64::MAX, 0_i64, false), + (u64::MAX, 0, true), + (1_920, 1_920, false), + (3_136, 3_136, false), + (1_920, 5_000, false), + (1_920, 256, false), + (1_920, 256, true), + ]; + for (form, (before, after, base, moved, continued)) in forms.iter().enumerate() { + for &(at, to, rebuild) in &scenarios { + let driver = Seeking { at, to }; + let mut fresh = Send::new(after, *moved); + let mut predecessor = Send::new(before, *base); + for index in 0..20_u64 { + if index == 18 { + predecessor.write(*moved, 2_000); + } + run_seeking(&mut predecessor, index * 64, &driver); + run_seeking(&mut fresh, index * 64, &driver); + } + let mut successor = Send::continuing(after, *moved, *continued); + swap(&mut successor, &mut predecessor); + for index in 20..120_u64 { + if rebuild && index == 25 { + let mut next = Send::continuing(after, *moved, *continued); + swap(&mut next, &mut successor); + successor = next; + } + run_seeking(&mut successor, index * 64, &driver); + run_seeking(&mut fresh, index * 64, &driver); + } + for column in 0..2 { + let want = fresh.route.ramps.ramps[column].target; + let got = successor.route.ramps.ramps[column]; + assert!( + aims(&got, want, got.length, column), + "form {form}, seek {at} -> {to}, rebuild {rebuild}, column {column}: \ + {:?} != {want:?}", + got.target + ); + } + } } - successor.run(49 * 64); - let ramp = successor.route.ramps.ramps[0]; - assert!( - aims(&ramp, open(OPEN, law(-6.0, 3.0)), 960, 0), - "{:?} over {}", - ramp.target, - ramp.length - ); - assert_eq!(successor.route.ramps.positions[0], 48); - assert_eq!(successor.route.ramps.ramps[1], record[1]); } /// U2 fold 1, MAJOR-4 (K1: a value jump keeps its length, `with_held_grid`): `ll` steps at @@ -21005,68 +20910,15 @@ mod send_automation_tests { ); } - /// U2 fold 2, MAJOR-A (R19-5, a column that loses its last curve): `ll` and `rr` ride flat, - /// a hand gain move (2,000 samples from 1,152) is in flight when a successor that keeps only - /// `rr` adopts the route at 1,280. Column 0 no longer hears a curve: its 2,000-sample move - /// runs to 3,152, where the column lands on its cell's value over `SendMatrix` (960); - /// column 1's move runs on. - /// - /// Red if the column retargets at the swap over the removed row's length, cutting the move. - #[test] - fn a_column_losing_its_last_curve_keeps_a_move_in_flight() { - let base = RouteTransform { gain: 1.0, ..OPEN }; - let moved = RouteTransform { gain: 0.5, ..OPEN }; - let before = Curves { - matrix: [Some(flat(0.5)), None, None, Some(flat(0.5))], - ..Curves::default() - }; - let after = Curves { - matrix: [None, None, None, Some(flat(0.5))], - ..Curves::default() - }; - let mut predecessor = Send::new(&before, base); - for index in 0..18_u64 { - predecessor.run(index * 64); - } - predecessor.write(moved, 2_000); - for index in 18..20_u64 { - predecessor.run(index * 64); - } - let record = predecessor.route.ramps.ramps; - let mut successor = - Send::continuing(&after, moved, [false, false, false, false, false, true]); - swap(&mut successor, &mut predecessor); - for index in 20..49_u64 { - successor.run(index * 64); - assert_eq!( - successor.route.ramps.ramps, record, - "block {index}: the move runs" - ); - } - successor.run(49 * 64); - let ramp = successor.route.ramps.ramps[0]; - assert!( - aims(&ramp, open(OPEN, 0.5), 960, 0), - "{:?} over {}", - ramp.target, - ramp.length - ); - assert_eq!(successor.route.ramps.positions[0], 48); - assert_eq!( - successor.route.ramps.ramps[1], record[1], - "column 1 runs on" - ); - } - - /// U2 fold 2, MAJOR-B (an owed landing's length is the column's, once): the gain rides and - /// the route's mute curve closes at 3,160 and reopens at 3,260; `ll` is removed at 1,280 with - /// a +3 dB hand move (2,000 samples from 1,152) in flight. The owed landing at 3,152 (cut by - /// the close) is spent; from 3,456 every column ramp is the twin's that never heard `ll` (the - /// gain lands at 3,460 over `SendGain`, 480). + /// U2 fold 2, MAJOR-B P2 (a removed row's landing length leaks to no later jump): the gain + /// rides and the route's mute curve closes at 3,160 and reopens at 3,260; `ll` is removed at + /// 1,280 with a +3 dB hand move (2,000 samples from 1,152) in flight, and the column lands at + /// once (B19-R7). From 3,456 every column ramp is the twin's that never heard `ll` (the gain + /// lands at 3,460 over `SendGain`, 480). /// - /// Red if the owed length stays on a cursor and stretches its later jumps. + /// Red if the removed row's length (960) stays on a cursor and stretches its later jumps. #[test] - fn an_owed_landing_lengthens_no_later_jump() { + fn a_removed_row_landing_lengthens_no_later_jump() { let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; let mute = vec![ seg(0, 3_160, 0.0, 1.0, Shape::Step), @@ -21115,13 +20967,13 @@ mod send_automation_tests { } } - /// U2 fold 2, MAJOR-B (a gate is never the owed landing's carrier): column 0 hears a source - /// mute curve and `ll`; `ll` is removed at 1,280 with a hand move in flight, and the + /// U2 fold 2, MAJOR-B P3 (a gate never carries a removed row's length): column 0 hears a + /// source mute curve and `ll`; `ll` is removed at 1,280 with a hand move in flight, and the /// successor's source mute closes at 2,400. The close fades over the mute length (200). /// - /// Red if the owed `SendMatrix` length (960) is put on the gate cursor. + /// Red if the removed row's `SendMatrix` length (960) is put on the gate cursor. #[test] - fn a_source_mute_close_before_an_owed_landing_fades_over_the_mute_length() { + fn a_source_mute_close_after_a_removed_row_fades_over_the_mute_length() { let base = RouteTransform { gain: 1.0, ..OPEN }; let moved = RouteTransform { gain: 0.5, ..OPEN }; let before = Curves { diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index a7a1268b6..ac5dcd10c 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -752,6 +752,31 @@ impl PlanStateInventory { pub fn carries_a_following_route_into_a_six_word_route(&self) -> bool { self.follow_into_six_word } + + /// Whether this plan's send join carries a following route of the plan it was prepared to + /// displace into a following route of its own that no longer hears one of the predecessor's + /// curves: a route row (`gain_db`, `mute`, a matrix entry) or a source lane's mute curve + /// (#1654, root ruling B19-R7). `false` for a plan prepared without a predecessor and for a + /// declared discontinuity, which carries nothing. + /// + /// **Conservative (B19-R7; TBD54 removes it).** Render lands such a column at once over the + /// removed row's own length at the swap, so a live send move still ramping on it is cut. The + /// control plane completes such a rebuild as + /// [`engine::realtime::CandidateOutcome::TransitionFallback`] while a live send move may still + /// ramp. It over-reports: the join reads the route's automated sets, not which column lost + /// the curve or whether that column moves at the swap. + #[must_use] + pub fn carries_a_following_route_that_loses_a_curve(&self) -> bool { + self.follow_loses_a_curve + } + + /// The longest ramp of the records this plan's send join writes to its carried following + /// routes (a live send move the successor starts at its first block, B19-R7), `0` when it + /// writes none. + #[must_use] + pub fn following_route_retarget_ramp(&self) -> u32 { + self.follow_retarget_ramp + } } /// Set `host`'s source-read offset (#1396 deliverable 2): the plan's @@ -823,6 +848,12 @@ pub struct PlanStateInventory { /// [`Self::carries_a_following_route_into_a_six_word_route`]). `false` for a plan prepared /// without a predecessor. follow_into_six_word: bool, + /// Whether this plan's send join carries a following route into a following route that hears + /// fewer curves (B19-R7; see [`Self::carries_a_following_route_that_loses_a_curve`]). + follow_loses_a_curve: bool, + /// The longest ramp of the join's following-route records (B19-R7; see + /// [`Self::following_route_retarget_ramp`]). + follow_retarget_ramp: u32, /// 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. @@ -1114,6 +1145,8 @@ impl PlanStateInventory { strip_delays: Box::default(), live_routes: Box::default(), follow_into_six_word: false, + follow_loses_a_curve: false, + follow_retarget_ramp: 0, restarted: Box::default(), input_floors: Box::default(), raised_arrivals: Box::default(), @@ -1147,6 +1180,8 @@ impl PlanStateInventory { strip_delays: Box::default(), live_routes: Box::default(), follow_into_six_word: false, + follow_loses_a_curve: false, + follow_retarget_ramp: 0, restarted: Box::default(), input_floors: self.input_floors.clone(), raised_arrivals: self.raised_arrivals.clone(), @@ -3710,6 +3745,8 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( strip_delays: strip_delays.into_boxed_slice(), live_routes: live_routes.into_boxed_slice(), follow_into_six_word: route_carry.follow_into_six_word, + follow_loses_a_curve: route_carry.follow_loses_a_curve, + follow_retarget_ramp: route_carry.follow_retarget_ramp, restarted: restarted.into_boxed_slice(), input_floors, raised_arrivals, @@ -6010,6 +6047,19 @@ struct RouteCarry<'a> { /// (#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, + /// Whether some move carries a following route into a following route that no longer hears + /// a row or source mute curve of the predecessor's (B19-R7, + /// [`PlanStateInventory::carries_a_following_route_that_loses_a_curve`]). + follow_loses_a_curve: bool, + /// The longest ramp of the following-route records among `retargets` (B19-R7). + follow_retarget_ramp: u32, +} + +/// Whether a following route of automated set `before` loses a curve in one of `after`: a source +/// lane's mute or a route row that `before` automates and `after` does not (B19-R7). +fn loses_a_curve(before: graph::RouteAutomatedSet, after: graph::RouteAutomatedSet) -> bool { + let lost = |had: &[bool], has: &[bool]| had.iter().zip(has).any(|(&had, &has)| had && !has); + lost(&before.lanes, &after.lanes) || lost(&before.rows, &after.rows) } /// The route half of the send join (#1284 D2): a send carries when both plans hold a live route of @@ -6022,11 +6072,17 @@ 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, rows[index].following)) + .map(|index| { + ( + rows[index].unit, + rows[index].following, + rows[index].automated, + ) + }) }; let mut carry = RouteCarry::default(); for join in joins { - let (Some((before, follows)), Some((after, successor_follows))) = + let (Some((before, follows, heard)), Some((after, successor_follows, hears))) = (row(predecessor, join.id), row(own, join.id)) else { continue; @@ -6037,12 +6093,20 @@ fn resolve_route_carry<'a>( // 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; + // B19-R7 (TBD54 deletes it): a carried following route that stays seven-word and loses + // a route row or a source mute curve lands that column at once over the removed row's + // length, cutting a live send move in flight there. The join flags every such carry; + // the control plane completes it as the fallback while a send move may still ramp. + carry.follow_loses_a_curve |= follows && successor_follows && loses_a_curve(heard, hears); carry.moves.push(graph::GraphRouteMove { successor: after, predecessor: before, predecessor_follows: follows, }); if let Some(record) = join.retarget { + if let SendRecord::Following(record) = record { + carry.follow_retarget_ramp = carry.follow_retarget_ramp.max(record.length()); + } carry.retargets.push((join.id, record)); } } @@ -6786,6 +6850,7 @@ mod tests { route_id: route.into(), unit, following: false, + automated: graph::RouteAutomatedSet::default(), }; let before = [live("a-main", 3), live("b-main", 5)]; let own = [live("a-main", 4), live("b-main", 2), live("c-main", 9)]; @@ -6851,6 +6916,7 @@ mod tests { route_id: route.into(), unit, following, + automated: graph::RouteAutomatedSet::default(), }; let join = |id| RouteJoin { id, retarget: None }; // (case, predecessor following, successor following, flagged) @@ -6872,6 +6938,65 @@ mod tests { assert!(carry.moves.is_empty() && !carry.follow_into_six_word); } + /// B19-R7: the join flags a carry of a following route into a following route whose + /// automated set lacks a curve of the predecessor's (a route row or a source lane's mute), + /// and only then: an unchanged or a larger set, and a kind change, leave it clear. + /// + /// Test value: red if the flag compares the sets for equality (an added row would flag), reads + /// only the rows (a removed source mute curve would complete exact) or ignores the kinds. + #[test] + fn the_join_flags_a_following_route_that_loses_a_curve() { + use super::{RouteJoin, resolve_route_carry}; + let set = |lanes: [bool; 2], rows: [bool; graph::GRAPH_ROUTE_ROWS]| { + graph::RouteAutomatedSet { lanes, rows } + }; + let ll = set([false; 2], [false, false, true, false, false, false]); + let ll_rr = set([false; 2], [false, false, true, false, false, true]); + let lane = set([true, false], [false; graph::GRAPH_ROUTE_ROWS]); + let none = graph::RouteAutomatedSet::default(); + let live = |unit, following, automated| graph::GraphLiveRoute { + route_id: "a-main".into(), + unit, + following, + automated, + }; + // (case, predecessor, successor, flagged) + for (case, before, after, flagged) in [ + ("same set", live(1, true, ll), live(2, true, ll), false), + ("adds a row", live(1, true, ll), live(2, true, ll_rr), false), + ("loses a row", live(1, true, ll_rr), live(2, true, ll), true), + ("swaps a row", live(1, true, ll), live(2, true, lane), true), + ( + "loses a source mute curve", + live(1, true, lane), + live(2, true, ll), + true, + ), + ( + "seven -> six", + live(1, true, ll), + live(2, false, none), + false, + ), + ( + "six -> seven", + live(1, false, none), + live(2, true, ll), + false, + ), + ] { + let carry = resolve_route_carry( + &[before], + &[after], + &[RouteJoin { + id: "a-main", + retarget: None, + }], + ); + assert_eq!(carry.follow_loses_a_curve, flagged, "{case}"); + } + } + /// #1283 D1: lines join by edge ID and endpoints, never by index. A line moves when its /// edge exists on both sides with the same endpoints, whatever its length and whatever /// index either side gave it; a route re-pointed to another destination or another source @@ -6929,6 +7054,8 @@ mod tests { strip_delays: Box::default(), live_routes: Box::default(), follow_into_six_word: false, + follow_loses_a_curve: false, + follow_retarget_ramp: 0, 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 47932db60..5f514de8d 100644 --- a/crates/host-core/tests/route_mute.rs +++ b/crates/host-core/tests/route_mute.rs @@ -2779,20 +2779,20 @@ fn a_running_send_ride_across_an_unrelated_rebuild_is_the_never_swapped_twin() { } } -/// #1654 U2 fold 2, MAJOR-A (every bank width and the scalar oracle). The send `t-b` hears flat +/// #1654 root ruling B19-R7 (every bank width and the scalar oracle). The send `t-b` hears flat /// `ll` and `rr` entries at 0.5; at block 5 a hand move of its gain to -6 dB is written through /// its seven-word producer, and a rebuild at block 6 removes the `ll` entry while the move is in -/// flight: the send's left source column no longer hears a curve. The move keeps its length on -/// both columns (both planes are the never-swapped twin's, bit for bit, until it ends), the left -/// column then lands on its static `ll` over `SendMatrix` (the pan length), and from the first -/// block boundary after that landing both planes are the successor prepared fresh, bit for bit. -/// Render allocates nothing. +/// flight: the send's left source column no longer hears a curve. Until the swap both planes are +/// the never-swapped twin's; at the swap the left column lands at once over `SendMatrix` (the pan +/// length), cutting the move there (the control plane flags that rebuild), while the right +/// column's move runs on; from the first block boundary after the move's end and the landing both +/// planes are the successor prepared fresh, bit for bit. Render allocates nothing. /// -/// Test value: red if a column that loses its last curve retargets at the swap and cuts the -/// live move in flight. +/// Test value: red if the removal never lands, lands late (after the move), or leaves the removed +/// row's value on the column. #[cfg(feature = "test-support")] #[test] -fn a_send_column_losing_its_last_curve_keeps_the_hand_move_in_flight() { +fn a_send_column_losing_its_last_curve_lands_at_once_across_the_hand_move() { use follow::*; use send_rows::{entry, flat}; use session::ParameterUnit; @@ -2820,7 +2820,8 @@ fn a_send_column_losing_its_last_curve_keeps_the_hand_move_in_flight() { move_end > SWAP_BLOCK * QUANTUM, "the move is in flight at the swap" ); - let landed = next_boundary(move_end + pan, QUANTUM); + let at = SWAP_BLOCK * QUANTUM; + let landed = next_boundary(move_end.max(at + pan), QUANTUM); assert!(landed < BLOCKS * QUANTUM, "the landing ends inside the run"); let hand = |block: usize, handles: &mut host_core::HostLiveControlHandles, @@ -2870,9 +2871,9 @@ fn a_send_column_losing_its_last_curve_keeps_the_hand_move_in_flight() { plane(&fresh, index), ); assert_bits_equal( - &send[..move_end], - &twin[..move_end], - &format!("{what}: plane {index}, the move runs to its end"), + &send[..at], + &twin[..at], + &format!("{what}: plane {index}, the twin until the swap"), ); assert_bits_equal( &send[landed..BLOCKS * QUANTUM], @@ -2880,9 +2881,17 @@ fn a_send_column_losing_its_last_curve_keeps_the_hand_move_in_flight() { &format!("{what}: plane {index}, the fresh plan after the landing"), ); } + // The landing starts at the swap: inside the move, the planes leave the twin's. assert!( - first_difference_from(&plane(&swapped, 0), &plane(&twin, 0), move_end).is_some(), - "{what}: not vacuous, the removed ll is heard after the move" + (0..2).any(|index| { + first_difference_from( + &plane(&swapped, index)[..move_end], + &plane(&twin, index)[..move_end], + at, + ) + .is_some() + }), + "{what}: the left column lands at the swap, inside the move" ); } } diff --git a/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index 5be40e410..1d174be95 100644 --- a/docs/C_ABI_V1_QUALIFICATION.md +++ b/docs/C_ABI_V1_QUALIFICATION.md @@ -151,7 +151,10 @@ 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 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; #1713 removes it; #1652 U2a fold 2). The frozen exported +(conservative: it can be set when no send moves at the swap; #1713 removes it; #1652 U2a fold 2). +It also reports a rebuild that carries a send into one that no longer hears one of its stored curves +while a live send move may still ramp (conservative; TBD54 removes it; #1654, root ruling B19-R7). +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 934fdba8f..9d548bc4e 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -7003,3 +7003,180 @@ fn removing_a_send_row_or_its_source_mute_curve_lands_alike_on_both_hosts() { ); } } + +/// #1654 root ruling B19-R7 on both hosts. `t1-b` hears flat `ll` and `rr` entries at 0.5; a hand +/// `gain_db` move to -6 dB over 1,000 samples (from block 4) is in flight when a rebuild before +/// block 6 removes the `ll` entry: the send's left column loses its last curve and lands at once +/// over `SendMatrix` (240), cutting the move there. On both hosts the rebuild completes +/// `TRANSITION_FALLBACK` (one watermark, count 1). Then: nothing (MAJOR-A), a second rebuild before +/// block 8 that adds a muted, inactive send (MAJOR-C: `EXACT`, it loses no curve), or a session +/// seek before block 7 inside the move, forward or back (MAJOR-D). From the first block boundary +/// after the move's end both hosts render, bit for bit, a fresh plan of the committed model fed +/// and seeked alike. The same removal before block 24, the move long ended, completes `EXACT`. +/// +/// Test value: red if either host completes the cut move's rebuild `EXACT` (or flags a removal +/// with no move), if a removed row's value survives the landing, a seek or a further rebuild, or +/// if the hosts disagree. +#[cfg(feature = "test-support")] +#[test] +fn removing_a_send_row_across_a_live_send_move_is_flagged_alike_on_both_hosts() { + use session::{AutomationShape, ParameterUnit}; + const RUN: u64 = 40; + const MOVE: u64 = 4; + const RAMP: u32 = 1_000; + let q = u64::from(QUANTUM); + let end = q * BLOCKS; + let flat = |value: f32| { + vec![send_piece( + AutomationShape::Linear, + (0, value), + (end, value), + ParameterUnit::Linear, + )] + }; + let mut model = follow_model(false, false); + model.automation.push(send_entry("t1-b-ll", 3, flat(0.5))); + model.automation.push(send_entry("t1-b-rr", 6, flat(0.5))); + let remove = vec![SessionEdit::RemoveAutomation { + automation_id: id("t1-b-ll"), + }]; + let mut muted_send = model + .routes + .iter() + .find(|route| route.id.as_str() == "t1-b") + .expect("the send") + .clone(); + muted_send.id = id("t2-b"); + muted_send.source = session::RouteSource::Track { + track_id: id(T2.id), + tap: session::SendTap::PreFader, + }; + muted_send.mute = true; + muted_send.follows_mute = false; + let added = vec![SessionEdit::UpsertRoute { + route: muted_send.clone(), + }]; + // The committed model after the move and the removal. + let mut committed = model.clone(); + committed + .automation + .retain(|entry| entry.id.as_str() != "t1-b-ll"); + t1_b(&mut committed).gain_db = -6.0; + let mut committed_added = committed.clone(); + committed_added.routes.push(muted_send); + committed_added + .routes + .sort_by(|left, right| left.id.as_str().cmp(right.id.as_str())); + // (label, rebuild block, second rebuild, seek (block, timeline block), flagged) + for (label, rebuild, second, seek, flagged) in [ + ("MAJOR-A", 6, false, None, true), + ("MAJOR-C", 6, true, None, true), + ("MAJOR-D inside", 6, false, Some((7, 7)), true), + ("MAJOR-D forward", 6, false, Some((7, 20)), true), + ("MAJOR-D back", 6, false, Some((7, 2)), true), + ("the move ended", 24, false, None, false), + ] { + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + let mut fresh = Rig::new(if second { &committed_added } else { &committed }); + let landed = (MOVE * q + u64::from(RAMP)) + .max(rebuild * q + 240) + .div_ceil(q); + let (mut generation, mut timeline) = (1_u64, 0_u64); + for block in 0..RUN { + if block == MOVE { + both_commit( + &mut browser, + &mut c_abi, + &send_edit(1, -6.0, Some(RAMP)), + EditPath::Live, + label, + ); + } + if block == rebuild { + both_commit(&mut browser, &mut c_abi, &remove, EditPath::Rebuild, label); + } + if second && block == rebuild + 2 { + both_commit(&mut browser, &mut c_abi, &added, EditPath::Rebuild, label); + } + if let Some((at, to)) = seek + && block == at + { + generation += 1; + timeline = to; + for host in [&mut browser.host, &mut fresh.host] { + assert_eq!( + host.session_seek(None, generation, to * q, None), + host_web::RESULT_OK + ); + } + c_abi.session_seek(generation, to * q, None); + } + let mut outputs = Vec::new(); + for leg in [&mut browser, &mut fresh] { + for (feed, strip) in [T1, T2, T3].into_iter().enumerate() { + let name = format!("src-{}", strip.id); + let [left, right] = planes(feed as u64, timeline); + assert_eq!( + leg.host.submit_source( + name.as_bytes(), + generation, + timeline * q, + leg.rate, + &[&left, &right], + QUANTUM, + false, + ), + host_web::RESULT_OK, + "{label}: browser source {name}" + ); + if outputs.is_empty() { + c_abi.submit_from(&name, feed as u64, timeline, timeline, generation); + } + } + 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(block); + assert_eq!(outputs[0], c, "{label}, block {block}: the two hosts agree"); + if block >= landed { + assert_eq!( + outputs[0], outputs[1], + "{label}, block {block}: the fresh plan after the landing" + ); + } + if block == rebuild { + // The block that adopts the removal. + let watermark = c_abi.watermark(); + assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); + assert_eq!( + status_watermark(&browser), + watermark, + "{label}: one watermark" + ); + let outcome = if flagged { + capi::OUTCOME_TRANSITION_FALLBACK + } else { + capi::OUTCOME_EXACT + }; + assert_eq!(watermark.2, outcome, "{label}: the removal's outcome"); + } + if second && block == rebuild + 2 { + assert_eq!( + c_abi.watermark().2, + capi::OUTCOME_EXACT, + "{label}: the second rebuild" + ); + } + timeline += 1; + } + assert_eq!( + ( + browser.host.watermark().transition_fallback_count, + c_abi.transition_fallback_count() + ), + (u64::from(flagged), u64::from(flagged)), + "{label}: the fallback count" + ); + } +} 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 bdce6d935..80095f1f3 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,9 @@ 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 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; #1713 removes it). + /// swap; #1713 removes it). It also reports a rebuild that carries a send into one that no + /// longer hears one of its stored curves while a live send move may still ramp (conservative; + /// TBD54 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 bdce6d935..80095f1f3 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -781,7 +781,9 @@ 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 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; #1713 removes it). + /// swap; #1713 removes it). It also reports a rebuild that carries a send into one that no + /// longer hears one of its stored curves while a live send move may still ramp (conservative; + /// TBD54 removes it). /// @internal readonly appliedOutcome: bigint; /// The module's memory size now: at least the boot size (`MisoAudioWorkletHost.memoryBytes`), From d4726fe615cb00fdd9541793ed8d5d6f6c1dd9cc Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 16:24:14 +0000 Subject: [PATCH 27/36] Correct two #1711 decision-record lines (U1 fold 2 verification NITs) Co-Authored-By: Claude Opus 5.5 --- ...-level-moves-own-length-across-a-reposition-swap.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 332b22c0d..4fbac9d3f 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 @@ -461,7 +461,8 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af column-jump deferral drops that cursor's deferral only when the jump emits (it then replaces the ramp: its grid moves again after its own jump, held by its own ramp by the cell rule); a jump that emits nothing keeps the deferral, which holds the grid to the column ramp's end and lands - there. The other cursors keep their deferrals. K = 1 still equals `CellEvents` (one rule); the K1 tests and the allocation test are + there. The other cursors keep their deferrals. K = 1 still equals `CellEvents` (one rule); the + K1 tests and the allocation test are green unchanged. - **MAJOR-1 gate (host-core, Simd8 and Simd4).** `a_table_step_inside_a_deferred_moves_ramp_drops_the_deferral` @@ -538,11 +539,12 @@ measured with temporary probes in a detached worktree at `323433f6a` (removed af the curve at `d - 1`, which differs, so `held` is set and the jump at `d` emits. That holds whenever a grid point lies in `[swap, d)`: always when the swap block's first node time is on the 64-sample grid, as in every host-core harness (quantum 128, no latency). The case is - reachable in principle only at a swap block whose first node time is off the grid (a quantum + reachable at a swap block whose first node time is off the grid (a quantum not a multiple of 64, or upstream latency, such as the 31 samples above) with `d` before the first grid point; there the fold-2 rule is the D1 behaviour (the deferral is kept, so the grid - does not cut the live move short). No gate here: the host-core moved-target harness swaps only at - multiples of 128; phase-2 line in the report. + does not cut the live move short). The U1 fold 2 verifier reached it in the host-web parity session + (quantum 128 with the 31-sample soft-clip latency): bit-equal to a fresh plan at the four rates, + `OUTCOME_EXACT`. No separate gate: the K1 gate goes red on any regression of the shared rule. - **MINOR-B fixed.** `a_moved_target_defers_its_jump_alike_on_both_hosts` (`hosts/host-web/tests/control_parity.rs`) now compares every block with a fresh pair whose table steps at `FREE` in the fader's node time (4 * 128 + 4,000 - `T1_FADER_LATENCY`), given the From 6a1638b4a9c8a8f7d595459ff2870df246c51b3a Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 16:34:43 +0000 Subject: [PATCH 28/36] Gate stored VCA automation on both hosts and document it (#1655 V5) Gates 1-8, R19-4 and the R19-6 reach-pair cap: flat VCA rides equal the static bits; members hear the ride in their own node time; a VCA mute closes a following send at its latent tap; hand edits follow D5 and the in-place path equals the full path; render is allocation-free; a VCA table edit keeps a level move's length (EXACT); one pair over the cap is refused host.resource.vca_reach_pairs on both hosts. pcm_digest unchanged. The schema doc names the VCA rows, D2's order and the VCA offset. Co-Authored-By: Claude Opus 5.5 --- ...r-stored-vca-offset-and-mute-automation.md | 69 ++ .../tests/vca_automation_realtime.rs | 120 ++- .../host-core/tests/stored_vca_automation.rs | 806 ++++++++++++++++++ docs/SESSION_SCHEMA_V1.md | 37 +- hosts/host-web/tests/control_parity.rs | 680 +++++++++++++++ 5 files changed, 1698 insertions(+), 14 deletions(-) diff --git a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md index f444f660f..2361523e0 100644 --- a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md +++ b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md @@ -661,3 +661,72 @@ test-support --test control_parity` (44): all green. Realtime policy: 172 region For V5: host gates for gate 3 (a following send of a member closes at its tap under a VCA mute, both hosts, latent tap), a member with no own mute entry, a nested VCA, the carry (continued and moved VCA mute tables) and the reach-pair cap gate (R19-6). + +## Decision record: V5 (batch 19) + +The objective gates and the doc. Tests and `docs/SESSION_SCHEMA_V1.md` only: no product code, no +edit under `crates/engine/src/realtime/`, no row class or table change (no SDK regeneration). The +gates found no defect. + +- **Hosts.** Every both-host gate is in `hosts/host-web/tests/control_parity.rs`, which drives the + C ABI's exported `miso_engine_v1_*` entries and the browser host in one binary (the amendment's + "both hosts in one binary"); `crates/capi/src/runtime/live_tests.rs` gains nothing (departure + from the brief's file list, not from the gates). Parity runs at quantum 128 and the native width; + widths and quanta are host-core's. +- **Widths.** `crates/host-core/tests/stored_vca_automation.rs` runs Simd8 (when `avx2`), Simd4 and + the scalar oracle (`backends()`), live and between render calls; the carry gates (8) run the bank + widths only (the oracle restarts at a swap, #1651 T4). + +Gates (test names; all green): +1. Flat equals static: host-core `a_flat_vca_ride_and_mute_render_the_static_vca_bits` (quanta 128, + 100, 37; a member with its own ride, one with none and in a second static VCA; fallback +3 + unused); both hosts `a_flat_vca_ride_and_mute_render_the_static_vca_bits_on_both_hosts`. +2. Member node time: host-core `a_vca_ride_reaches_each_member_in_its_own_node_time` (each grid + event's gain equals `checked_fader_gain(vca_compose_db(own, S + c(τ)))` computed on the test + thread; the latent member's events `L` later); both hosts + `a_linear_vca_ride_renders_alike_on_both_hosts` (alike and heard). +3. Mute: host-core `a_vca_mute_step_mutes_each_member_at_its_node_time` (off-grid step at 1,000; + `SetMute` at `1,000` and `1,000 + L` over `VcaMute`; a member with no own mute entry, one with, + a nested VCA; static-mute and open plans after the ramps); both hosts + `a_vca_mute_curve_closes_a_following_send_at_its_latent_tap_on_both_hosts` (also the V4 note: the + send follows a lane only a VCA mute reaches, through a nested VCA, behind a latent tap). +4. Hand edits: both hosts `hand_edits_of_an_automated_vca_commit_as_d5_says_on_both_hosts` (offset + +3 live and heard; hand mute over an open curve heard, over a closed one no bit moves; a hand + move of a member only the ride reaches live and heard; membership edit of the automated VCA + `rebuild` and `EXACT`, no fallback; of a plain VCA `live`) and + `a_hand_move_on_an_automated_vca_offset_is_in_place_as_the_full_path_on_both_hosts` (R17-1; the + document stores the offset). The `own_db` record and "no `FaderDb`" stay V3's classifier gates. +5. Realtime: `crates/control-plane/tests/vca_automation_realtime.rs` + `a_vca_curve_and_hand_edits_over_it_render_allocation_free` (curve, hand VCA move, hand member + move, hand VCA mute and unmute; every block `allocations == 0 && frees == 0`). +6. No bit moves: `./target/release/audit capi` `pcm_digest` = `bffc21e857b5e134`. +7. Reposition: both hosts + `a_vca_ride_table_edit_while_a_level_move_ramps_completes_exactly_on_both_hosts` (a member's + `own_db` move and a VCA hand move of 4,000 samples, heard 1, 12, 26 blocks; a table change after + 1,000,000 repositions every member; each host equals its own twin without the rebuild; `EXACT`). +8. Carry: host-core `a_hand_vca_mute_in_a_vca_mute_curve_rebuild_lands_over_its_own_ramp` (ramp + `M`, 0, 2,000) and `added_removed_and_changed_vca_mute_entries_carry_the_member_stage` (swap + block allocation-free, strip carried); both hosts + `a_hand_vca_mute_in_a_vca_mute_curve_rebuild_lands_over_its_own_ramp_on_both_hosts`. +- R19-4: host-core `coincident_member_and_vca_events_take_one_longest_jump` (one fader jump over + `J`, one `SetMute` over `M`; bits of one curve making the sum). At host level the `VcaOffset` and + `VcaMute` lengths equal the fader and mute lengths, so "longest" is V2's + `coincident_member_and_vca_events_take_the_longest_jump` (480/960) at the stage. +- R19-6: both hosts `the_reach_pair_cap_bounds_vca_automation_alike_on_both_hosts` (256 nested VCAs, + the outermost automated: 64 tracks, 16,384 pairs, boot; 65 tracks refused + `host.resource.vca_reach_pairs` alike, no panic). + +Red without the feature (once each, reverted): gates 1-3 with the VCA reach dropped from +`controls::event_lanes` (VCA curves reach no member): host-core gates 1, 2, 3 red (and gate 8's +carry case); gate 1 and 3 parity red; gate 4 red with the same knockout (a hand VCA move is not +heard: `automated_vca_alike`'s heard assert); gate 7 red with `settle_adoption`'s stage hold +ignored (the move is cut at the swap: the browser twin comparison). Knocking out +`automation_row_renders` for VCA rows alone leaves gates 1-3 green: render reads the event-lane +predicates, the classifier the rows. + +Commands: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` on host-core, +control-plane, host-web; `cargo check --locked --all-targets` (workspace, no features); tests +automation (44), builtins-compiler `--features test-support` (94), graph `--features test-support` +(159), host-core `--features test-support` (485) and `test-support,control-provider` (613), +control-plane `--features test-support` (30), capi lib (220), host-web `--features test-support +--test control_parity` (52): all green. Realtime policy: 172 regions in 35 files (unchanged). diff --git a/crates/control-plane/tests/vca_automation_realtime.rs b/crates/control-plane/tests/vca_automation_realtime.rs index 740166665..83c297583 100644 --- a/crates/control-plane/tests/vca_automation_realtime.rs +++ b/crates/control-plane/tests/vca_automation_realtime.rs @@ -11,6 +11,10 @@ //! //! #1650b gate 6 runs the same rig with hand moves of `eq0`'s automated fader (`SET_CONTROL //! fader_db`, which writes the ride's offset and the lane's offsets cell) in place of the VCA moves. +//! +//! #1655 gate 5 runs it with `v` automated itself (an `offset_db` ride and `mute` steps): the +//! composed curves' events, a hand move of the automated VCA offset, a hand move of the member +//! with no own automation (an `own_db` write) and hand VCA mutes, each block allocation-free. use control_plane::{ CompiledChildren, ControlAdapter, ControlLimits, EventLane, PlanResources, PlanState, @@ -100,8 +104,9 @@ fn id(text: &str) -> StableId { } /// The fixture with `eq0` riding its fader on both lanes (linear, -0 to -12 dB over the source) -/// and VCA `v` holding `eq0` and `eq1`. -fn document() -> (String, u64) { +/// and VCA `v` holding `eq0` and `eq1`; with `vca_automation`, `v` also rides its `offset_db` (linear, 0 to -9 dB over +/// the source) and steps its `mute` (on at 1,000, off at 2,600, on at 5,000, off at 6,000). +fn document_with(vca_automation: bool) -> (String, u64) { let mut model = session::parse_session_json(SESSION).expect("fixture"); let frames = model.sources[0].frames; model.automation.push(Automation { @@ -132,6 +137,48 @@ fn document() -> (String, u64) { }, members: vec![id("eq0"), id("eq1")], }); + if vca_automation { + let vca_row = |control| ControlAddress { + owner: Owner::Vca(id("v")), + section: Section::Own, + control, + lane: ParameterChannel::Both, + }; + let step = |start, end, from, to| AutomationSegment { + shape: AutomationShape::Step, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: ParameterUnit::Linear, + }; + model.automation.extend([ + Automation { + id: id("v-ride"), + target: vca_row(1), + segments: vec![AutomationSegment { + shape: AutomationShape::Linear, + start_sample: 0, + end_sample: frames, + start_value: 0.0, + end_value: -9.0, + unit: ParameterUnit::Db, + }], + offset: AutomationOffset::default(), + }, + Automation { + id: id("v-mute"), + target: vca_row(2), + segments: vec![ + step(0, 1_000, 0.0, 1.0), + step(1_000, 2_600, 1.0, 0.0), + step(2_600, 5_000, 0.0, 1.0), + step(5_000, 6_000, 1.0, 0.0), + ], + offset: AutomationOffset::default(), + }, + ]); + } ( session::canonical_session_json(&model).expect("canonical"), model.revision, @@ -149,9 +196,14 @@ struct Rig { } impl Rig { - /// A rig on [`document`], booted. + /// A rig on [`document_with`]`(false)`, booted. fn new() -> Self { - let (document, revision) = document(); + Self::with(false) + } + + /// A rig on [`document_with`], booted. + fn with(vca_automation: bool) -> Self { + let (document, revision) = document_with(vca_automation); let CompiledChildren { session, plan } = compile_children::(&document, limits(), Box::new(Preparer)) .expect("boot compile"); @@ -264,9 +316,13 @@ impl Rig { } } -/// A rig on [`document`], warmed: the process statics and the first blocks' one-time work. +/// A rig on [`document_with`]`(false)`, warmed: the process statics and the first blocks' one-time work. fn warm_rig() -> Rig { - let mut rig = Rig::new(); + warm(Rig::new()) +} + +/// `rig` warmed: four blocks. +fn warm(mut rig: Rig) -> Rig { for _ in 0..4 { rig.block(); } @@ -362,3 +418,55 @@ fn a_hand_move_on_an_automated_fader_renders_allocation_free() { } } } + +/// #1655 gate 5. With `v` automated (its ride and mute steps compose into both members' lanes), +/// every render block after the warm-up allocates and frees nothing: blocks that only play the +/// composed curves (grid events of the ride, the mute steps), a hand move of the automated VCA +/// offset (it writes the ride's offset and `S` of both members), a hand move of `eq1`'s fader +/// (no own automation: an `own_db` write through its offsets cell), and a hand mute and unmute of +/// `v` (the members' `terms`, ORed with the curve). Each edit commits live. Not vacuous: the +/// composed curves are heard (the output differs from the rig without VCA automation). +/// +/// Test value: red if the composed walk, the VCA mute cursors, the `own_db` read or a terms write +/// on a composed lane allocates or frees on render. +#[test] +fn a_vca_curve_and_hand_edits_over_it_render_allocation_free() { + bench_support::alloc::assert_installed(); + let mut rig = warm(Rig::with(true)); + let mut plain = warm_rig(); + let row = |owner: Owner, section: Section, control: u32| ControlAddress { + owner, + section, + control, + lane: ParameterChannel::Both, + }; + let offset = row(Owner::Vca(id("v")), Section::Own, 1); + let mute = row(Owner::Vca(id("v")), Section::Own, 2); + let member = row(Owner::Strip(id("eq1")), Section::Builtins, 5); + let script: [&[(&ControlAddress, f32, Option)]; 6] = [ + &[], + &[(&offset, 3.0, None)], + &[(&member, -6.0, Some(256))], + &[(&mute, 1.0, None)], + &[(&mute, 0.0, Some(256))], + &[(&offset, -1.5, Some(256)), (&member, 0.0, None)], + ]; + let mut heard = false; + for moves in script { + for &(address, value, ramp) in moves { + rig.move_row(address.clone(), value, ramp); + } + for _ in 0..12 { + let block = rig.block; + let used = rig.block(); + plain.block(); + heard |= rig.last != plain.last; + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "block {block} allocated or freed" + ); + } + } + assert!(heard, "the composed curves are heard"); +} diff --git a/crates/host-core/tests/stored_vca_automation.rs b/crates/host-core/tests/stored_vca_automation.rs index c91e1dff8..44336c2ec 100644 --- a/crates/host-core/tests/stored_vca_automation.rs +++ b/crates/host-core/tests/stored_vca_automation.rs @@ -341,6 +341,812 @@ fn every_fader_event_lane_has_an_offsets_cell() { } } +// --------------------------------------------------------------------------------------------- +// #1655 V5: the objective gates on every bank width and the scalar oracle +// --------------------------------------------------------------------------------------------- + +/// The session mute length here (`mute_ms` 10 at 48 kHz): the `VcaMute` row's length. +const M: u32 = 480; +/// The session fader length here (`fader_ms` 5 at 48 kHz): the `VcaOffset` row's length. +const J: u32 = 240; + +/// One segment of `shape` over `[start, end)` from `from` to `to` in `unit`. +fn piece( + shape: AutomationShape, + (start, end): (u64, u64), + (from, to): (f32, f32), + unit: ParameterUnit, +) -> AutomationSegment { + AutomationSegment { + shape, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit, + } +} + +/// Steps from `from` at sample 0 to each `(sample, value)` of `steps` in `unit`, held after. +fn steps_of(from: f32, steps: &[(u64, f32)], unit: ParameterUnit) -> Vec { + let mut at = (0_u64, from); + steps + .iter() + .map(|&(sample, value)| { + let step = piece(AutomationShape::Step, (at.0, sample), (at.1, value), unit); + at = (sample, value); + step + }) + .collect() +} + +/// An entry `name` on `owner`'s row `control` in `section` on both lanes, with `segments`. +fn entry_of( + name: &str, + owner: Owner, + section: Section, + control: u32, + segments: Vec, +) -> Automation { + Automation { + id: id(name), + target: ControlAddress { + owner, + section, + control, + lane: ParameterChannel::Both, + }, + segments, + offset: session::AutomationOffset::default(), + } +} + +/// VCA `name`'s `mute` entry stepping from `from` at each `(sample, value)` of `steps`. +fn vca_mute_steps(name: &str, from: f32, steps: &[(u64, f32)]) -> Automation { + entry_of( + &format!("{name}-mute"), + Owner::Vca(id(name)), + Section::Own, + VCA_MUTE, + steps_of(from, steps, ParameterUnit::Linear), + ) +} + +/// `model` with a true-peak limiter (5 ms lookahead) on track `track`: a latent node before its +/// fader. +fn with_limiter(mut model: SessionModel, track: usize) -> SessionModel { + use session::{ + Effect, EffectIdentity, EffectParam, EffectQuality, LinkMode, SidechainDeclaration, + }; + let param = |parameter_id, unit, value| EffectParam { + parameter_id, + channel: ParameterChannel::Both, + unit, + value, + }; + model.tracks[track].inserts.effects.push(Effect { + id: id(&format!("limiter-{track}")), + identity: EffectIdentity::Native { + effect_id: id("miso.true-peak-limiter"), + }, + quality: EffectQuality::Normal, + bypass: false, + link_mode: LinkMode::Maximum, + params: vec![ + param(1, ParameterUnit::Db, -8.0), + param(2, ParameterUnit::Milliseconds, 60.0), + param(3, ParameterUnit::Milliseconds, 5.0), + ], + sidechain: SidechainDeclaration::None, + }); + normalized(&model) +} + +/// The floored input arrival `a(n)` of track `track`'s fader node in `prepared`'s plan. +fn fader_arrival(prepared: &PreparedHost, model: &SessionModel, track: usize) -> u64 { + let track_id = model.tracks[track].id.as_str(); + prepared + .inventory + .input_floors() + .iter() + .find(|(node, _)| { + matches!( + node, + graph::GraphNodeId::TrackStage { + track_id: id, + stage: graph::TrackStage::PostFader, + } if id.as_str() == track_id + ) + }) + .map_or(0, |(_, arrival)| *arrival) +} + +/// The probe's fader and mute events of `blocks` more blocks of `rig`, taken block by block. +fn probed(rig: &mut Rig, blocks: usize) -> Vec { + let _ = builtins_compiler::test_only_take_fader_events(); + let mut all = Vec::new(); + for _ in 0..blocks { + rig.block(); + let (events, overflow) = builtins_compiler::test_only_take_fader_events(); + assert!(!overflow, "the probe holds a block's events"); + all.extend(events); + } + all +} + +fn same_bits(a: &[f32], b: &[f32]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.to_bits() == b.to_bits()) +} + +/// Gate 1 (#1655, amended): a flat VCA `offset_db` ride at -6 dB (its static fallback +3 dB +/// unused) and a flat VCA `mute` at 0 on `va` render, bit for bit, the session whose `va` holds +/// -6 dB statically: `t0` is in `va` and in the static `vb` (-2 dB; two VCAs on one member, one +/// automated) and has no ride of its own, `t1` rides its own fader (flat -3 dB), `t2` is in no +/// VCA. On every backend, live and between render calls, at quanta 128, 100 and 37, render +/// allocation-free. Not vacuous: a ride at -5 dB is heard. +/// +/// Test value: red if the composed lane adds the VCA curve to `S` with other arithmetic or in +/// another order than the static composition (D2's class B order: static `vb` first, then the +/// curve), if the fallback leaks into `S`, or if a flat mute curve moves a bit. +#[test] +fn a_flat_vca_ride_and_mute_render_the_static_vca_bits() { + for quantum in [128, 100, 37] { + let session = |ride: Option| { + let mut model = plain(3, quantum); + let t: Vec = model.tracks.iter().map(|track| track.id.clone()).collect(); + let mut va = vca("va", vec![t[0].clone(), t[1].clone()]); + let mut vb = vca("vb", vec![t[0].clone()]); + (vb.fader.left_db, vb.fader.right_db) = (-2.0, -2.0); + if let Some(db) = ride { + (va.fader.left_db, va.fader.right_db) = (3.0, 3.0); + model.automation.extend([ + vca_ride("va", ParameterChannel::Both, db), + vca_mute_entry("va"), + ]); + } else { + (va.fader.left_db, va.fader.right_db) = (-6.0, -6.0); + } + model.vcas = vec![va, vb]; + let own = own_ride(&model, 1, -3.0); + model.automation.push(own); + normalized(&model) + }; + let (flat, fixed, other) = (session(Some(-6.0)), session(None), session(Some(-5.0))); + for backend in backends() { + for between in [false, true] { + let what = format!("{backend:?} between {between}, quantum {quantum}"); + let run = |model: &SessionModel| { + let mut rig = Rig::new(model, backend, between); + rig.blocks(40); + assert_eq!(rig.allocations, (0, 0), "{what}: the render thread"); + rig.out + }; + let (flat, fixed, other) = (run(&flat), run(&fixed), run(&other)); + for lane in 0..2 { + assert!( + same_bits(&flat[lane], &fixed[lane]), + "{what}: lane {lane} renders the static VCA's bits" + ); + assert!( + !same_bits(&other[lane], &fixed[lane]), + "{what}: lane {lane}, a ride at -5 dB is heard" + ); + } + } + } + } +} + +/// Gate 2 (#1655): a linear VCA `offset_db` ride (-12 to 0 dB over 8,192 samples) reaches two +/// members, `t0` behind a true-peak limiter (latency `L`, its own fader static at -1 dB) and `t1` +/// (its own fader riding -6.5 to -2 dB over 4,096). Every grid event the driver gives either +/// member's stage carries the fader dB `vca_compose_db(own, S + c(τ))` computed here, `τ` the +/// event's completion sample in that member's node time (render sample less `a(n)`, plus 63), so +/// its gain is `checked_fader_gain` of it; the latent member's events fall on the render samples +/// `L` after the other's. On every backend, live and between render calls. +/// +/// Test value: red if VCA events run on the render clock or another member's arrival (the latent +/// member's grid would fall on the plain member's samples and carry another time's value), or if +/// the composition differs from `vca_compose_db(own, S + c)`. +#[test] +fn a_vca_ride_reaches_each_member_in_its_own_node_time() { + let mut model = plain(2, 128); + let t: Vec = model.tracks.iter().map(|track| track.id.clone()).collect(); + ( + model.tracks[0].fader.left_db, + model.tracks[0].fader.right_db, + ) = (-1.0, -1.0); + model.vcas = vec![vca("va", t.clone())]; + let vca_curve = vec![piece( + AutomationShape::Linear, + (0, 8_192), + (-12.0, 0.0), + ParameterUnit::Db, + )]; + let own_curve = vec![piece( + AutomationShape::Linear, + (0, 4_096), + (-6.5, -2.0), + ParameterUnit::Db, + )]; + model.automation = vec![ + entry_of( + "va-ride", + Owner::Vca(id("va")), + Section::Own, + VCA_OFFSET, + vca_curve, + ), + entry_of( + "t1-ride", + Owner::Strip(t[1].clone()), + Section::Builtins, + FADER_DB, + own_curve, + ), + ]; + let model = with_limiter(model, 0); + let entries = model.automation.clone(); + let curve = |name: &str, at: i64| { + let entry = entries + .iter() + .find(|entry| entry.id.as_str() == name) + .expect("an entry"); + automation::value_at(&controls::stored_segments(entry), at) + }; + for backend in backends() { + for between in [false, true] { + let what = format!("{backend:?} between {between}"); + let mut rig = Rig::new(&model, backend, between); + let latent = fader_arrival(&rig.prepared, &model, 0); + assert_eq!(fader_arrival(&rig.prepared, &model, 1), 0, "{what}"); + assert!( + !latent.is_multiple_of(64), + "{what}: L = {latent} is off the grid" + ); + let events = probed(&mut rig, 30); + let mut samples = [Vec::new(), Vec::new()]; + for event in events.iter().filter(|event| { + !event.mute && !event.held && event.kind == automation::EventKind::Grid + }) { + // The member by its grid: `t1`'s node time is render time, `t0`'s is `L` behind. + let member = usize::from(!event.sample.is_multiple_of(64)); + let arrival = [0, latent][member]; + assert_eq!( + (event.sample - arrival) % 64, + 0, + "{what}: {event:?} on a member's grid" + ); + samples[member].push(event.sample); + let at = (event.sample - arrival) as i64 + 63; + if at >= 4_096 { + continue; + } + let own = if member == 0 { + curve("t1-ride", at) + } else { + -1.0 + }; + let expected = session::vca_compose_db(own, 0.0 + f64::from(curve("va-ride", at))); + assert_eq!( + builtins::checked_fader_gain(event.value) + .expect("fader domain") + .to_bits(), + builtins::checked_fader_gain(expected) + .expect("fader domain") + .to_bits(), + "{what}: {event:?} at node time {at}" + ); + } + for list in &mut samples { + list.sort_unstable(); + list.dedup(); + } + assert!( + samples.iter().all(|list| list.len() >= 40), + "{what}: both members move on their grids: {samples:?}" + ); + let shifted: Vec = samples[0] + .iter() + .map(|sample| sample + latent) + .filter(|sample| (latent + 64..3_840).contains(sample)) + .collect(); + let latent_grid: Vec = samples[1] + .iter() + .copied() + .filter(|sample| (latent + 64..3_840).contains(sample)) + .collect(); + assert_eq!( + shifted, latent_grid, + "{what}: the latent member's events fall L later" + ); + assert_eq!(rig.allocations, (0, 0), "{what}: the render thread"); + } + } +} + +/// Gate 3 (#1655, amended): a VCA `mute` curve on `va` closes at timeline 1,000 (not a grid +/// sample) and opens at 2,500. `va` holds `t0` (behind a true-peak limiter, no mute entry of its +/// own), `t1` (a flat open mute entry of its own) and the nested VCA `vn`, which holds `t2`; `t3` +/// is in no VCA. Each member mutes at its own node time over the `VcaMute` length: the driver's +/// `SetMute` events fall on render samples `1,000` (`t1`, `t2`) and `1,000 + L` (`t0`) and back at +/// `2,500` and `2,500 + L`, each over `M`. From the first block boundary after the mute ramp's end +/// in the output until the unmute, the output equals the plan with `va` muted statically; from +/// the first boundary after the unmute ramp, the plan with no VCA mute. On every backend, live +/// and between render calls. +/// +/// Test value: red if a member's mute follows the render clock (the latent member closes `L` +/// early), if a nested member or one with no own mute entry misses the VCA curve, or if the +/// composed mute ramps over another length. +#[test] +fn a_vca_mute_step_mutes_each_member_at_its_node_time() { + let session = |curve: bool, muted: bool| { + let mut model = plain(4, 128); + let t: Vec = model.tracks.iter().map(|track| track.id.clone()).collect(); + let mut va = vca("va", vec![t[0].clone(), t[1].clone(), id("vn")]); + (va.fader.left_mute, va.fader.right_mute) = (muted, muted); + model.vcas = vec![va, vca("vn", vec![t[2].clone()])]; + model.automation.push(entry_of( + "t1-mute", + Owner::Strip(t[1].clone()), + Section::Builtins, + 6, + steps_of(0.0, &[(4_096, 0.0)], ParameterUnit::Linear), + )); + if curve { + model + .automation + .push(vca_mute_steps("va", 0.0, &[(1_000, 1.0), (2_500, 0.0)])); + } + with_limiter(model, 0) + }; + let (curve, muted, open) = ( + session(true, false), + session(false, true), + session(false, false), + ); + const BLOCKS: usize = 40; + for backend in backends() { + for between in [false, true] { + let what = format!("{backend:?} between {between}"); + let mut rig = Rig::new(&curve, backend, between); + let latent = fader_arrival(&rig.prepared, &curve, 0); + assert!(latent > 0, "{what}: the limiter delays t0's fader"); + let events = probed(&mut rig, BLOCKS); + let mut mutes: Vec<(u64, bool, u32)> = events + .iter() + .filter(|event| event.mute) + .map(|event| (event.sample, event.value != 0.0, event.ramp)) + .collect(); + mutes.sort_unstable(); + mutes.dedup(); + assert_eq!( + mutes, + [ + (1_000, true, M), + (1_000 + latent, true, M), + (2_500, false, M), + (2_500 + latent, false, M), + ], + "{what}: each member's mute at its node time over VcaMute" + ); + let mut fixed = Rig::new(&muted, backend, between); + fixed.blocks(BLOCKS); + let mut plain = Rig::new(&open, backend, between); + plain.blocks(BLOCKS); + let q = rig.quantum as u64; + let closed = ((1_000 + latent + u64::from(M)).div_ceil(q) * q) as usize; + let opened = ((2_500 + latent + u64::from(M)).div_ceil(q) * q) as usize; + let end = BLOCKS * rig.quantum; + for lane in 0..2 { + assert!( + same_bits( + &rig.out[lane][closed..2_500], + &fixed.out[lane][closed..2_500] + ), + "{what}: lane {lane} equals the static VCA mute while the curve mutes" + ); + assert!( + same_bits(&rig.out[lane][opened..end], &plain.out[lane][opened..end]), + "{what}: lane {lane} equals the open plan after the unmute" + ); + assert!( + !same_bits(&fixed.out[lane][..900], &rig.out[lane][..900]), + "{what}: lane {lane} plays before the mute" + ); + } + assert_eq!(rig.allocations, (0, 0), "{what}: the render thread"); + } + } +} + +/// R19-4 at host level (#1655): `t0`'s own fader steps from -3 to -9 dB at timeline 1,000 and +/// VCA `va`'s ride steps from 0 to -6 dB at the same sample; `t0`'s own mute and `va`'s mute +/// curve each step on at 3,000. The driver gives each channel one fader event at 1,000 over the +/// longest of the two jumps (the fader and `VcaOffset` rows' lengths, both `J` here) and one +/// `SetMute` at 3,000 over `M`, and the output equals, bit for bit, a session whose own curves +/// alone make the composed moves (-3 to -15 dB, one mute step) under a static `va` at 0 dB. On +/// every backend, live and between render calls. +/// +/// Test value: red if coincident curve events land as two jumps (the second retargets inside the +/// first), as a shorter jump, or compose another value than one curve making the sum would. +#[test] +fn coincident_member_and_vca_events_take_one_longest_jump() { + let session = |vca_curves: bool| { + let mut model = plain(1, 128); + let t0 = model.tracks[0].id.clone(); + model.vcas = vec![vca("va", vec![t0.clone()])]; + let to = if vca_curves { -9.0 } else { -15.0 }; + model.automation = vec![ + entry_of( + "t0-ride", + Owner::Strip(t0.clone()), + Section::Builtins, + FADER_DB, + steps_of(-3.0, &[(1_000, to)], ParameterUnit::Db), + ), + entry_of( + "t0-mute", + Owner::Strip(t0.clone()), + Section::Builtins, + 6, + steps_of(0.0, &[(3_000, 1.0)], ParameterUnit::Linear), + ), + ]; + if vca_curves { + model.automation.extend([ + entry_of( + "va-ride", + Owner::Vca(id("va")), + Section::Own, + VCA_OFFSET, + steps_of(0.0, &[(1_000, -6.0)], ParameterUnit::Db), + ), + vca_mute_steps("va", 0.0, &[(3_000, 1.0)]), + ]); + } + normalized(&model) + }; + let (composed, single) = (session(true), session(false)); + for backend in backends() { + for between in [false, true] { + let what = format!("{backend:?} between {between}"); + let mut rig = Rig::new(&composed, backend, between); + let events = probed(&mut rig, 32); + let at = |sample: u64, mute: bool| { + let mut found: Vec<(usize, u32)> = events + .iter() + .filter(|event| event.sample == sample && event.mute == mute && !event.held) + .map(|event| (event.channel, event.ramp)) + .collect(); + found.sort_unstable(); + found + }; + assert_eq!(at(1_000, false), [(0, J), (1, J)], "{what}: one fader jump"); + assert_eq!(at(3_000, true), [(0, M), (1, M)], "{what}: one mute step"); + let mut twin = Rig::new(&single, backend, between); + twin.blocks(32); + for lane in 0..2 { + assert!( + same_bits(&rig.out[lane], &twin.out[lane]), + "{what}: lane {lane} equals one curve making the sum" + ); + } + } + } +} + +// Gate 8 (#1655 amendment): the strip carry of VCA mute automation across a plan swap. + +/// The block a carry case's successor is swapped in at. +const SWAP_BLOCK: usize = 8; + +/// A signed two-plane source that is never zero. +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: the planes, the swap block's `(allocations, frees)` and the +/// successor's restarted strips. +struct CarryRun { + out: [Vec; 2], + swap_allocations: (u64, u64), + restarted: Vec>, +} + +/// A strip record write of a carry run: before block `block` renders, through the live handles +/// and gate of the plan that renders it. +type CarryEdit<'a> = &'a dyn Fn( + usize, + &mut host_core::HostLiveControlHandles, + &mut engine::realtime::PlanRevisionGate, +); + +/// `a` prepared with live controls on `backend` and rendered for [`SWAP_BLOCK`] blocks, then, with +/// `b = Some((next, committed))`, `next` prepared as its successor on the committed model +/// `committed`, swapped in and rendered to `frames`; with `None`, `a` alone. `edit` runs before +/// every block on the plan that renders it. Fed `pcm` from frame 0. +fn carry_run( + a: &SessionModel, + b: Option<(&SessionModel, &SessionModel)>, + backend: graph_compiler::Backend, + pcm: &[Vec; 2], + frames: usize, + edit: CarryEdit<'_>, +) -> 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 (prepared, mut handles) = host_core::test_only_prepare_host_runtime_with_live_controls_on( + &compiled(a), + &caps, + &request, + backend, + ) + .unwrap_or_else(|failure| fail(failure)); + let PreparedHost { + mut plan, + mut sources, + inventory, + .. + } = prepared; + let mut gate = plan.take_revision_gate().expect("the plan's 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 mut out = [Vec::new(), Vec::new()]; + let mut swap_allocations = (0, 0); + let mut restarted = Vec::new(); + let mut successor: Option<( + host_core::SourceControlSet, + host_core::HostLiveControlHandles, + engine::realtime::PlanRevisionGate, + )> = None; + let mut block = 0; + while block * quantum < frames { + if block == SWAP_BLOCK + && let Some((next, committed)) = b + { + let committed = compiled(committed).normalized_model().clone(); + let revision = gate.last_published() + 1; + let base = host_core::SuccessorBase::new(&inventory, &committed, revision); + let (mut prepared, next_handles) = + host_core::test_only_prepare_host_runtime_with_live_controls_successor_on( + &compiled(next), + &caps, + &request, + base, + 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; + let mut next_gate = plan.take_revision_gate().expect("the successor's gate"); + next_gate.publish(revision); + publisher + .reserve_replacement(plan, engine::realtime::PlanAdoption::Next) + .unwrap_or_else(|_| panic!("reserve the successor")) + .commit(); + successor = Some((next_sources, next_handles, next_gate)); + } + let at = block * quantum; + let (feed, live, live_gate) = match successor.as_mut() { + Some((sources, handles, gate)) => (sources, handles, gate), + None => (&mut sources, &mut handles, &mut gate), + }; + feed.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"); + edit(block, live, live_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); + drop(successor); + CarryRun { + out: out.map(|mut plane| { + plane.truncate(frames); + plane + }), + swap_allocations, + restarted, + } +} + +/// The bank backends of the target (the scalar oracle binds the fader as a graph node, which a +/// swap restarts: it has no carry to hold, #1651 T4). +fn bank_backends() -> Vec { + backends() + .into_iter() + .filter(|backend| *backend != graph_compiler::Backend::Scalar) + .collect() +} + +/// A VCA mute curve: its value at sample 0 and its steps. +type MuteCurve = Option<(f32, &'static [(u64, f32)])>; + +/// Two tracks at `quantum`, `t0` in VCA `va` with `va`'s mute curve `curve`, if any, and `va` +/// muted statically when `muted`. +fn vca_mute_session(quantum: u32, curve: MuteCurve, muted: bool) -> SessionModel { + let mut model = plain(2, quantum); + let mut va = vca("va", vec![model.tracks[0].id.clone()]); + (va.fader.left_mute, va.fader.right_mute) = (muted, muted); + model.vcas = vec![va]; + if let Some((from, steps)) = curve { + model.automation = vec![vca_mute_steps("va", from, steps)]; + } + normalized(&model) +} + +/// Gate 8 (#1655 amendment, U1 fold 1 of #1651 for the composed mute): a hand mute of `va` +/// committed in the same transaction as a change of `va`'s mute curve lands over its own ramp. +/// The curve closes `t0` at 1,500 and opens it at 2,500; at block 8 (the curve open) a rebuild +/// moves the close to 1,600 and the committed model holds `va` muted, whose `Mute` record on `t0` +/// (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 on +/// every sample, at both bank widths and quanta 128 and 100; the swap block allocates nothing and +/// the strip carries. +/// +/// Test value: red if the swap block's adoption of the repositioned VCA mute cursor drops the +/// record's ramp word and lands the hand mute over the mute length (a `0` step becomes a +/// 480-sample ramp, a 2,000-sample fade becomes 480), or lands it twice. +#[test] +fn a_hand_vca_mute_in_a_vca_mute_curve_rebuild_lands_over_its_own_ramp() { + for ramp in [M, 0, 2_000] { + let mute = move |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 [128, 100] { + let a = vca_mute_session(quantum, Some((0.0, &[(1_500, 1.0), (2_500, 0.0)])), false); + let b = vca_mute_session(quantum, Some((0.0, &[(1_600, 1.0), (2_500, 0.0)])), true); + let q = quantum as usize; + let frames = 4_096_usize.div_ceil(q) * q; + let pcm = carry_pcm(frames); + for backend in bank_backends() { + let what = format!("ramp {ramp}, {backend:?}, quantum {quantum}"); + let twin = carry_run(&a, None, backend, &pcm, frames, &mute).out; + let swapped = carry_run(&a, Some((&b, &a)), backend, &pcm, frames, &mute); + assert_eq!(swapped.swap_allocations, (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!( + same_bits(out, twin), + "{what}: plane {plane} equals the hand mute alone" + ); + } + } + } + } +} + +/// Gate 8 (#1655 amendment): an added, a removed and a changed VCA `mute` entry each rebuild and +/// carry the member's stage. (a) Added: `va`'s new curve holds `t0` muted until 2,600; (b) +/// removed: the old curve held it muted until 3,600; (c) changed: the old curve closes at 3,000, +/// the new one is closed from 500 to 2,600. Swapped in at block 8, the strip carries, the swap +/// block allocates nothing, the output equals the predecessor's before the swap and a fresh plan +/// of the new model from the first block boundary after the `VcaMute` ramp (at both bank widths, +/// quanta 128 and 100), and it ramps there (it is not set). +/// +/// Test value: red if a carried member lane does not take on a gained VCA mute curve (it stays +/// open), keeps a lost one's state (it stays muted), continues a changed table's cursor (the +/// unmute at 2,600 is missed), restarts the strip, or allocates at the swap. +#[test] +fn added_removed_and_changed_vca_mute_entries_carry_the_member_stage() { + let cases: [(&str, MuteCurve, MuteCurve); 3] = [ + ("added", None, Some((1.0, &[(2_600, 0.0)]))), + ("removed", Some((1.0, &[(3_600, 0.0)])), None), + ( + "changed", + Some((0.0, &[(3_000, 1.0)])), + Some((0.0, &[(500, 1.0), (2_600, 0.0)])), + ), + ]; + for (label, before, after) in cases { + for quantum in [128, 100] { + let a = vca_mute_session(quantum, before, false); + let b = vca_mute_session(quantum, after, false); + 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; + let none = |_: usize, + _: &mut host_core::HostLiveControlHandles, + _: &mut engine::realtime::PlanRevisionGate| {}; + for backend in bank_backends() { + let what = format!("{label}, {backend:?}, quantum {quantum}"); + let fresh = carry_run(&b, None, backend, &pcm, frames, &none).out; + let old = carry_run(&a, None, backend, &pcm, frames, &none).out; + let swapped = carry_run(&a, Some((&b, &a)), backend, &pcm, frames, &none); + assert_eq!(swapped.swap_allocations, (0, 0), "{what}: the swap block"); + assert!(swapped.restarted.is_empty(), "{what}: the strip carries"); + for plane in 0..2 { + assert!( + same_bits(&swapped.out[plane][..at], &old[plane][..at]), + "{what}: plane {plane} before the swap" + ); + assert!( + same_bits(&swapped.out[plane][settled..], &fresh[plane][settled..]), + "{what}: plane {plane} from the first block boundary after the ramp" + ); + assert!( + !same_bits(&swapped.out[plane][at..settled], &fresh[plane][at..settled]), + "{what}: plane {plane} ramps at the swap" + ); + } + } + } + } +} + #[cfg(feature = "control-provider")] mod classifier { use super::*; diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index 536513af0..59ca283c1 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, mute and send rows render; every other row is inert today.** An entry on a strip's fader +**The fader, mute, send and VCA 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 @@ -316,8 +316,28 @@ session's send gain and send matrix ramp lengths (`fader_ms`, `pan_ms`); the sen `step` only and moves over the mute ramp length. A flat ride renders the bits of the same static send. The send's mute is the curve's OR its static (hand) `mute`, as the strip's is; a hand edit of an automated matrix row is stored and commits `model_only` (automation wins), and a live edit of a -send row the session does not automate stays live. A send's `follows_mute` (row 7) is not -automatable (#1632). A route into the output stays prepared: its rows are rebuild-only, so an entry +send row the session does not automate stays live. An entry on a VCA's own rows (#1655: row 1 +`offset_db` and row 2 `mute`, per lane) is compiled into the same table after every send entry, one +table per automated VCA lane, and rendered on the C ABI and in the browser alike through the VCA's +members, since a VCA has no audio: each member lane the VCA reaches, directly or through nested +VCAs, follows the VCA curves in its own fader node time, so a member behind a latent insert hears +the VCA move with its own audio. A member lane's fader dB is `vca_compose_db(own, S + c_1 + ... + +c_m)`: `own` is the lane's own curve when its fader is automated, else its static fader dB; the +`c_k` are the reaching automated VCA curves in ascending VCA ID; the sum is in `f64`, `S` first, and +`S` holds the lane's own hand offset, then the static offsets of the reaching VCAs no entry drives, +then the hand offsets of the reaching automated VCAs, each group in ascending VCA ID. Static terms +come first, then the automated curves: a class B summation order under the standing +summation-order ruling (with F16), equal to the static path's ascending-ID order except in F16's +degenerate cases, so a flat VCA ride renders the bits of the same static VCA offset. Events of +several curves at one sample take one jump over the longest of their rows' lengths. A member +lane's mute is its own curve OR each reaching VCA `mute` curve OR its other mute terms, and a +following send of a member follows the VCA mute curves in its tap's node time. A hand move of an +automated VCA `offset_db` writes the entry's offset (live), a hand VCA mute ORs with the curve +(live), and a hand fader move of a member with no fader entry of its own stays live. An edit of a +VCA entry, and a change of which member lanes an automated VCA reaches (membership, nesting, or an +automated VCA added or removed), is a rebuild; a VCA no entry drives keeps its live edits. A +session without VCA automation renders the bits it rendered before. A send's `follows_mute` (row 7) +is not automatable (#1632). A route into the output stays prepared: its rows are rebuild-only, so an entry on one is refused `control.automation.target`. 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 @@ -348,8 +368,9 @@ fields 1 and 2. A missing record or lane is `schema.missing_field` and a third k `schema.unknown_field`, at their paths. The row rule (`controls::automation_diagnostics`) judges each lane at `$.automation[id=].offset.left_db` or `.right_db`, after the segments: -- `fader_db` (builtins control 5 of a strip) and a send's `gain_db` (own control 1 of a route into - a submix, #1654) take an offset (`takes_offset`). The input trim does not yet: the slice that +- `fader_db` (builtins control 5 of a strip), a send's `gain_db` (own control 1 of a route into + a submix, #1654) and a VCA's `offset_db` (own control 1, per lane, #1655) take an offset + (`takes_offset`). The input trim does not yet: the slice that renders it (#1658) adds it. The send gain row is shared, so its entry holds one offset: `right_db` must have the bits of `left_db` (`control.automation.offset` at `.offset.right_db` otherwise, `+0.0` with `-0.0` included), in the route gain domain `[-144, 24]` dB. @@ -357,8 +378,8 @@ each lane at `$.automation[id=].offset.left_db` or `.right_db`, after the se lane the entry does not drive (`right_db` of a `left` entry, `left_db` of a `right` entry) and every lane of an entry whose row takes no offset. `-0.0` is refused there too, so "no offset" has one spelling. -- A lane a `fader_db` entry drives holds an offset in the fader's own domain, `[-144, 24]` dB (the - VCA offset domain), else `control.domain`. `-0.0` is a valid zero there. +- A lane a `fader_db` or VCA `offset_db` entry drives holds an offset in the fader's own domain, + `[-144, 24]` dB (the VCA offset domain), else `control.domain`. `-0.0` is a valid zero there. The offset of a lane belongs to the entry that drives the lane, so a lane-granular hand move on a `both` entry needs no entry split. `RemoveAutomation` removes the offset with the entry; @@ -366,7 +387,7 @@ The offset of a lane belongs to the entry that drives the lane, so a lane-granul new target. The offset is stored, checked, round-tripped (canonical JSON, wire, SDK) and heard (#1650b): render composes each fader event of the lane as `vca_compose_db(curve, S)`, where `S` is the `f64` sum of the lane's own offset first and then the reaching VCA offsets in ascending VCA -ID. On a lane a rendered `fader_db` entry drives, the `fader_db` row reads and writes the offset: +ID (with VCA automation, the order of the VCA rows above). On a lane a rendered `fader_db` entry drives, the `fader_db` row reads and writes the offset: `SET_CONTROL fader_db` sets it (live, one offsets-cell write; a `both` write sets both lanes), `CONTROL_STATE_GET` and the control list report it as the row's value, and the list marks the row `automated` (`ControlEntry` field 35). The static fader value of an automated lane is its fallback, diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 934fdba8f..d46eeea49 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -7003,3 +7003,683 @@ fn removing_a_send_row_or_its_source_mute_curve_lands_alike_on_both_hosts() { ); } } + +// --------------------------------------------------------------------------------------------- +// #1655 V5: stored VCA offset and mute automation on both hosts +// --------------------------------------------------------------------------------------------- + +/// #1655: one segment of `shape` over `[start, end)` from `from` to `to` in `unit`. +fn vca_piece( + shape: session::AutomationShape, + (start, end): (u64, u64), + (from, to): (f32, f32), + unit: session::ParameterUnit, +) -> session::AutomationSegment { + session::AutomationSegment { + shape, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit, + } +} + +/// #1655: an entry on VCA `vca`'s row `control` (1 `offset_db`, 2 `mute`) on both lanes, named +/// `{vca}-ride` or `{vca}-mute`. +fn vca_entry( + vca: &str, + control: u32, + segments: Vec, +) -> session::Automation { + let suffix = if control == VCA_OFFSET_DB { + "ride" + } else { + "mute" + }; + session::Automation { + id: id(&format!("{vca}-{suffix}")), + target: address( + Owner::Vca(id(vca)), + Section::Own, + control, + ParameterChannel::Both, + ), + segments, + offset: session::AutomationOffset::default(), + } +} + +/// #1655: a flat `offset_db` ride of `vca` at `db` over the run. +fn vca_flat_ride(vca: &str, db: f32) -> session::Automation { + let end = u64::from(QUANTUM) * BLOCKS; + vca_entry( + vca, + VCA_OFFSET_DB, + vec![vca_piece( + session::AutomationShape::Linear, + (0, end), + (db, db), + session::ParameterUnit::Db, + )], + ) +} + +/// #1655: a `mute` entry of `vca` stepping from `from` at each `(sample, value)` of `steps`. +fn vca_mute_steps(vca: &str, from: f32, steps: &[(u64, f32)]) -> session::Automation { + let mut at = (0_u64, from); + let segments = steps + .iter() + .map(|&(sample, value)| { + let piece = vca_piece( + session::AutomationShape::Step, + (at.0, sample), + (at.1, value), + session::ParameterUnit::Linear, + ); + at = (sample, value); + piece + }) + .collect(); + vca_entry(vca, VCA_MUTE, segments) +} + +/// #1655 gate 1 on both hosts. On [`automated_vca_model`] (`v` holds `t2`, which rides its own +/// fader, and `t3`, which has none and sits behind the true-peak limiter) with VCA `w` at -2 dB +/// also holding `t3` (two VCAs on one member, one automated): a flat `offset_db` ride of `v` at -6 +/// dB (its static fallback +3 dB unused) and a flat `mute` entry at 0 render, on each host and +/// alike, the bits of the session whose `v` holds -6 dB statically. Not vacuous: a ride at -5 dB +/// is heard. +/// +/// Test value: red if either host composes the VCA curve into a member's fader with other +/// arithmetic or order than the static path, if the fallback leaks into `S`, if a flat mute +/// curve moves a bit, or if the hosts disagree. +#[test] +fn a_flat_vca_ride_and_mute_render_the_static_vca_bits_on_both_hosts() { + const RUN: u64 = 24; + let session = |ride: Option| { + automated_vca_model(48_000, |model| { + let static_db = if ride.is_some() { 3.0 } else { -6.0 }; + model.vcas = vec![ + vca_at(VCA.id, static_db, &[T2.id, T3.id]), + vca_at("w", -2.0, &[T3.id]), + ]; + if let Some(db) = ride { + model.automation.extend([ + vca_flat_ride(VCA.id, db), + vca_mute_steps(VCA.id, 0.0, &[(u64::from(QUANTUM) * BLOCKS, 0.0)]), + ]); + } + }) + }; + let fixed = both_run(&session(None), RUN, &[]); + assert_eq!( + both_run(&session(Some(-6.0)), RUN, &[]), + fixed, + "the static VCA's bits" + ); + assert_ne!( + both_run(&session(Some(-5.0)), RUN, &[]), + fixed, + "a ride at -5 dB is heard" + ); +} + +/// #1655 gate 2 on both hosts. A linear `offset_db` ride of `v` (0 to -12 dB over the run) over +/// `t2` (own ride) and `t3` (behind the true-peak limiter) renders the same bits on both hosts in +/// every block, and is heard (a static `v` at 0 dB renders otherwise). Each member's exact gain at +/// its own node time is host-core's gate (`stored_vca_automation`, +/// `a_vca_ride_reaches_each_member_in_its_own_node_time`), which both hosts run. +/// +/// Test value: red if either host prepares the VCA ride otherwise than the shared preparation +/// (another member arrival, another composition), or does not render it at all. +#[test] +fn a_linear_vca_ride_renders_alike_on_both_hosts() { + const RUN: u64 = 32; + let end = u64::from(QUANTUM) * BLOCKS; + let riding = automated_vca_model(48_000, |model| { + model.automation.push(vca_entry( + VCA.id, + VCA_OFFSET_DB, + vec![vca_piece( + session::AutomationShape::Linear, + (0, end), + (0.0, -12.0), + session::ParameterUnit::Db, + )], + )); + }); + let ridden = both_run(&riding, RUN, &[]); + let still = both_run(&automated_vca_model(48_000, |_| {}), RUN, &[]); + assert!( + (8..RUN as usize).all(|block| ridden[block] != still[block]), + "the ride is heard" + ); +} + +/// #1655 gate 3's session on both hosts: [`follow_model`]`(false, false)` with a true-peak limiter +/// on `t1` (so `t1-b`'s `pre_fader` tap and `t1`'s fader sit `L` behind render time), `t1` in VCA +/// `n` (no own mute entry), `n` nested in `v`, which also holds `t2`. With `curve`, `v`'s mute +/// closes at 1,000 and opens at 2,500; with `muted`, `v` is muted statically. +fn vca_follow_model(curve: bool, muted: bool) -> SessionModel { + let limiter = model_at(48_000).tracks[2].inserts.effects[LIMITER.position as usize].clone(); + let mut model = follow_model(false, false); + model + .tracks + .iter_mut() + .find(|track| track.id.as_str() == T1.id) + .expect("t1") + .inserts + .effects + .push(limiter); + let mut v = vca_at(VCA.id, 0.0, &[T2.id, "n"]); + (v.fader.left_mute, v.fader.right_mute) = (muted, muted); + model.vcas = vec![vca_at("n", 0.0, &[T1.id]), v]; + if curve { + model + .automation + .push(vca_mute_steps(VCA.id, 0.0, &[(1_000, 1.0), (2_500, 0.0)])); + } + model +} + +/// #1655 gate 3 and the V4 note on both hosts. `v`'s mute curve reaches `t1` through the nested +/// VCA `n`; `t1` has no mute entry of its own, so its following send `t1-b` follows a lane that +/// only a VCA mute reaches (it prepares the seven-word cell and renders), and its tap sits behind +/// the limiter. Both hosts render the same bits. From the first block boundary after the mute +/// ramp's end in the output (1,000 plus the plan latency plus the 480-sample `VcaMute` ramp) to +/// the unmute, each host's output equals the plan with `v` muted statically; from the first +/// boundary after the unmute ramp, the plan with no VCA mute. Not vacuous: before the mute the +/// curve's plan differs from the statically muted one. +/// +/// Test value: red if a following send of a member keeps passing its pre-fader audio into `b` +/// under an automated VCA mute (the send ignores the VCA curve, or follows it on the render clock +/// instead of its tap's node time), if a nested member misses the curve, if a VCA-only-lane send +/// is refused, or if the hosts disagree. +#[test] +fn a_vca_mute_curve_closes_a_following_send_at_its_latent_tap_on_both_hosts() { + const BLOCKS: u64 = 40; + let quantum = u64::from(QUANTUM); + let curve_model = vca_follow_model(true, false); + let latency = browser_plan_latency(&mut Rig::new(&curve_model)).0; + assert!(latency > 0, "the limiter delays the output"); + let curve = both_run(&curve_model, BLOCKS, &[]); + let muted = both_run(&vca_follow_model(false, true), BLOCKS, &[]); + let open = both_run(&vca_follow_model(false, false), BLOCKS, &[]); + let first_after = |sample: u64| sample.div_ceil(quantum); + let closed = first_after(1_000 + latency + 480)..2_500 / quantum; + assert!(!closed.is_empty()); + for block in closed { + assert_eq!( + curve[block as usize], muted[block as usize], + "block {block}: the statically muted plan's bits while the VCA curve mutes" + ); + } + for block in first_after(2_500 + latency + 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[4], muted[4], + "the members and the send play before the mute" + ); +} + +/// #1655 gate 4's session: [`automated_vca_model`] with `v` riding its `offset_db` (0 to -12 dB +/// over the run) and a flat `mute` entry at `mute` (0 open, 1 closed). +fn vca_hand_model(mute: f32) -> SessionModel { + let end = u64::from(QUANTUM) * BLOCKS; + automated_vca_model(48_000, |model| { + model.automation.extend([ + vca_entry( + VCA.id, + VCA_OFFSET_DB, + vec![vca_piece( + session::AutomationShape::Linear, + (0, end), + (0.0, -12.0), + session::ParameterUnit::Db, + )], + ), + vca_mute_steps(VCA.id, mute, &[(end, mute)]), + ]); + }) +} + +/// #1655 gate 4 on both hosts (D5 and root's ruling). On [`vca_hand_model`], each edit commits +/// with the same verdict and reply on both hosts, both render the same bits, and from the end of +/// its jump each renders the committed session's boot: +/// - `SET_CONTROL offset_db = +3` on the automated `v` is `live` and heard (the stored offset); +/// - a hand mute of `v` is `live`: over an open curve it closes the members (heard), over a closed +/// curve nothing moves (the OR); +/// - a hand fader move of `t3`, which only `v`'s ride reaches, is `live` and heard (its `own_db`); +/// - adding `t1` to the automated `v` rebuilds and completes `EXACT` with no transition fallback +/// on both hosts; the same edit of a `v` no entry drives is `live` and heard. +/// +/// Test value: red if a hand move of an automated VCA writes the fallback (or commits +/// `model_only`), if a hand VCA mute replaces the curve instead of ORing with it, if a plain +/// member's hand move is lost under the VCA curve, if an automated VCA's reach change commits live +/// (no event state for the new pair) or completes otherwise than `EXACT`, or if the hosts disagree. +#[test] +fn hand_edits_of_an_automated_vca_commit_as_d5_says_on_both_hosts() { + let open = vca_hand_model(0.0); + let closed = vca_hand_model(1.0); + let lengths = open.control_smoothing_samples(); + let vca_mute = own_edit(Owner::Vca(id(VCA.id)), VCA_MUTE, 1.0); + let t3_fader = SessionEdit::set_control( + address( + Owner::Strip(id(T3.id)), + Section::Builtins, + FADER_DB, + Lane::Both, + ), + -6.0, + None, + ); + let cases: [(&str, &SessionModel, SessionEdit, u32, bool); 4] = [ + ( + "offset_db = +3", + &open, + vca_offset(VCA.id, 3.0, None), + lengths.fader, + true, + ), + ( + "a hand mute over an open curve", + &open, + vca_mute.clone(), + lengths.mute, + true, + ), + ( + "a hand mute over a closed curve", + &closed, + vca_mute, + lengths.mute, + false, + ), + ("a hand move of t3", &open, t3_fader, lengths.fader, true), + ]; + for (label, model, edit, jump, heard) in cases { + automated_vca_alike(label, model, &[edit], EditPath::Live, jump, heard); + } + let reach = SessionEdit::UpsertVca { + vca: vca_at(VCA.id, 0.0, &[T1.id, T2.id, T3.id]), + }; + // The same edit of a `v` at -3 dB that no entry drives: `t1` hears it. + automated_vca_alike( + "a membership edit of a VCA no entry drives", + &automated_vca_model(48_000, |model| { + model.vcas = vec![vca_at(VCA.id, -3.0, &[T2.id, T3.id])]; + }), + &[SessionEdit::UpsertVca { + vca: vca_at(VCA.id, -3.0, &[T1.id, T2.id, T3.id]), + }], + EditPath::Live, + lengths.fader, + true, + ); + let label = "a membership edit of the automated VCA"; + let mut browser = Rig::new(&open); + let mut c_abi = CAbi::new(&open); + same_blocks(&mut browser, &mut c_abi, 0..4, label); + both_commit( + &mut browser, + &mut c_abi, + std::slice::from_ref(&reach), + EditPath::Rebuild, + label, + ); + same_blocks(&mut browser, &mut c_abi, 4..12, label); + let watermark = c_abi.watermark(); + assert_eq!( + status_watermark(&browser), + watermark, + "{label}: one watermark" + ); + assert_eq!( + (watermark.0, watermark.2), + (open.revision + 1, capi::OUTCOME_EXACT), + "{label}: the 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), + "{label}: no transition fallback on either host" + ); +} + +/// #1655 gate 4 (R17-1) on both hosts: `SET_CONTROL offset_db = +3` on the automated `v` of +/// [`vca_hand_model`] commits in place on both hosts, and the committed document stores `+3` as +/// the ride's offset on both fields with `v`'s static offset kept. The same transaction by the full +/// path (`control_plane::test_force_full_preparation`) gives the same reply, document and bits of +/// every block, alike on both hosts. +/// +/// Test value: red if the in-place path answers or renders otherwise than the full path for an +/// automated VCA offset (an owners model without the VCA entry writes the static offset or commits +/// `model_only`), or if the two hosts disagree. +#[cfg(feature = "test-support")] +#[test] +fn a_hand_move_on_an_automated_vca_offset_is_in_place_as_the_full_path_on_both_hosts() { + const EDIT: u64 = 4; + const END: u64 = 20; + let model = vca_hand_model(0.0); + let mut expected = model.clone(); + expected.revision = model.revision + 1; + expected + .automation + .iter_mut() + .find(|entry| entry.id.as_str() == "v-ride") + .expect("the ride") + .offset = session::AutomationOffset { + left_db: 3.0, + right_db: 3.0, + }; + let edits = [vca_offset(VCA.id, 3.0, None)]; + let leg = |label: &str| { + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + same_blocks(&mut browser, &mut c_abi, 0..EDIT, label); + let (b, reply) = browser.transaction_reply(&edits); + let (c, c_reply) = c_abi.transaction(&edits); + assert_eq!(b, c, "{label}: one verdict"); + assert_eq!(reply, c_reply, "{label}: one reply"); + assert_eq!( + b, + Verdict::Applied { + path: EditPath::Live, + revision: model.revision + 1 + }, + "{label}: a hand move is live" + ); + let committed = snapshot_bytes(|payload, expected| browser.command(payload, expected)); + let blocks: Vec> = (EDIT..END) + .map(|block| { + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{label}: block {block} alike on both hosts"); + b + }) + .collect(); + (reply, committed, blocks) + }; + control_plane::test_take_in_place_counts(); + let in_place = leg("in place"); + assert_eq!( + control_plane::test_take_in_place_counts(), + (2, 0), + "both hosts commit the hand move in place" + ); + assert_eq!( + in_place.1, + canonical_session_json(&expected) + .expect("canonical") + .as_bytes(), + "the ride stores +3 on both fields and the static offset stays" + ); + control_plane::test_force_full_preparation(true); + let full = leg("full path"); + control_plane::test_force_full_preparation(false); + assert_eq!( + control_plane::test_take_in_place_counts().0, + 0, + "the full leg commits nothing in place" + ); + assert_eq!(full.0, in_place.0, "one reply on both paths"); + assert_eq!(full.1, in_place.1, "one document"); + assert_eq!(full.2, in_place.2, "the same bits on both paths"); +} + +/// #1655 gate 7 on both hosts (#1711's deferred jump on the composed state). [`model_at`] with +/// `v` holding `t2` and `t3` (neither automated on its own) and riding its `offset_db` linearly +/// from 0 to -12 dB up to 1,000,000. A live level move with its own 4,000 samples (a hand move of +/// `t3`'s fader, its `own_db`; a hand move of `v`'s offset, the ride's offset), heard 1, 12 and 26 +/// blocks, then a rebuild whose only change is the ride's table (a segment appended after +/// 1,000,000: every reached member channel repositions, and no heard value moves). Both hosts +/// give one verdict and reply per edit and render the same bits; every block after the rebuild +/// equals, on each host, the same host's twin that took the move and no rebuild (the move keeps +/// its length); the rebuild completes `EXACT` with no transition fallback. +/// +/// Test value: red if a repositioned composed channel cuts the move in flight at the swap (the +/// landing jumps to the curve over the fader length instead of deferring to the move's first +/// free sample), if either host carries the composed state differently, or if the rebuild is +/// flagged or reported otherwise than `EXACT`. +#[test] +fn a_vca_ride_table_edit_while_a_level_move_ramps_completes_exactly_on_both_hosts() { + let ride = |appended: bool| { + let mut segments = vec![vca_piece( + session::AutomationShape::Linear, + (0, 1_000_000), + (0.0, -12.0), + session::ParameterUnit::Db, + )]; + if appended { + segments.push(vca_piece( + session::AutomationShape::Linear, + (1_000_000, 2_000_000), + (-12.0, -6.0), + session::ParameterUnit::Db, + )); + } + vca_entry(VCA.id, VCA_OFFSET_DB, segments) + }; + let mut model = model_at(48_000); + model.vcas = vec![vca_at(VCA.id, 0.0, &[T2.id, T3.id])]; + model.automation.push(ride(false)); + let t3_move = CAbi::edits( + Edit::Fader { + strip: T3, + lanes: Lanes::Both, + db: -6.0, + }, + Some(4_000), + ); + let moves = [ + ("a hand move of t3", t3_move), + ( + "a hand move of v", + vec![vca_offset(VCA.id, -6.0, Some(4_000))], + ), + ]; + for (what, edits) in &moves { + for heard in [1_u64, 12, 26] { + let label = &format!("{what}, heard {heard}"); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + let mut twin = Rig::new(&model); + let mut c_twin = CAbi::new(&model); + for block in 0..4 { + let b = browser.step(block); + assert_eq!(b, c_abi.step(block), "{label}: block {block}"); + assert_eq!(b, twin.step(block), "{label}: block {block}"); + c_twin.step(block); + } + both_commit(&mut browser, &mut c_abi, edits, EditPath::Live, label); + both_commit(&mut twin, &mut c_twin, edits, EditPath::Live, label); + let mut block = 4; + let mut lockstep = |browser: &mut Rig, + c_abi: &mut CAbi, + twin: &mut Rig, + c_twin: &mut CAbi, + blocks: u64| { + for _ in 0..blocks { + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{label}: block {block} alike on both hosts"); + assert_eq!( + b, + twin.step(block), + "{label}: block {block}, the browser twin" + ); + assert_eq!( + c, + c_twin.step(block), + "{label}: block {block}, the C ABI twin" + ); + block += 1; + } + }; + lockstep(&mut browser, &mut c_abi, &mut twin, &mut c_twin, heard); + let rebuild = [SessionEdit::UpsertAutomation { + automation: { + let mut automation = ride(true); + if *what == "a hand move of v" { + automation.offset = session::AutomationOffset { + left_db: -6.0, + right_db: -6.0, + }; + } + automation + }, + }]; + both_commit(&mut browser, &mut c_abi, &rebuild, EditPath::Rebuild, label); + lockstep(&mut browser, &mut c_abi, &mut twin, &mut c_twin, 8); + let watermark = c_abi.watermark(); + assert_eq!( + status_watermark(&browser), + watermark, + "{label}: one watermark" + ); + assert_eq!( + watermark.2, + capi::OUTCOME_EXACT, + "{label}: the outcome flags" + ); + 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), + "{label}: no transition fallback on either host" + ); + } + } +} + +/// #1655 gate 8 on both hosts (#1651 U1 fold 1 for the composed mute). On [`follow_model`]`(false, +/// false)` with `v` holding `t2` and a mute curve closing at 1,500 and opening at 2,500, before +/// block 8 (the curve open) one transaction moves the close to 1,600 (a rebuild) and mutes `v` by +/// hand over `ramp` (none: the session's length; 0; 2,000). On each host every block equals a twin +/// that took only the hand mute (`live`) at the same block: the curve's change lies inside the +/// span the hand mute holds, so the hand mute lands over its own ramp across the swap. Both hosts +/// render the same bits. +/// +/// Test value: red if the swap's adoption of the repositioned VCA mute cursor drops the terms +/// record's ramp word (the hand mute lands over the mute length, or twice), or if the hosts +/// disagree. +#[test] +fn a_hand_vca_mute_in_a_vca_mute_curve_rebuild_lands_over_its_own_ramp_on_both_hosts() { + let session = |close: u64| { + let mut model = follow_model(false, false); + model.vcas = vec![vca_at(VCA.id, 0.0, &[T2.id])]; + model + .automation + .push(vca_mute_steps(VCA.id, 0.0, &[(close, 1.0), (2_500, 0.0)])); + model + }; + let model = session(1_500); + let moved = session(1_600) + .automation + .into_iter() + .next() + .expect("the curve"); + for ramp in [None, Some(0), Some(2_000)] { + let label = &format!("ramp {ramp:?}"); + let hand = SessionEdit::set_control( + address(Owner::Vca(id(VCA.id)), Section::Own, VCA_MUTE, Lane::Both), + 1.0, + ramp, + ); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + let mut twin = Rig::new(&model); + let mut c_twin = CAbi::new(&model); + for block in 0..32 { + if block == 8 { + both_commit( + &mut browser, + &mut c_abi, + &[ + SessionEdit::UpsertAutomation { + automation: moved.clone(), + }, + hand.clone(), + ], + EditPath::Rebuild, + label, + ); + both_commit( + &mut twin, + &mut c_twin, + std::slice::from_ref(&hand), + EditPath::Live, + label, + ); + } + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{label}: block {block} alike on both hosts"); + assert_eq!( + b, + twin.step(block), + "{label}: block {block}, the browser twin" + ); + assert_eq!( + c, + c_twin.step(block), + "{label}: block {block}, the C ABI twin" + ); + } + } +} + +/// R19-6 on both hosts: VCA automation adds no host cap of its own; its pair memory is bank +/// payload under the existing caps, and the reach-pair cap still bounds it. [`bound_model`] with +/// the outermost of 256 nested VCAs riding its `offset_db` and stepping its `mute` (so every +/// track is a fader and mute event lane, reached by every VCA): at 64 tracks (16,384 reach pairs) +/// both hosts boot; at 65 tracks (one track, 256 pairs, over) both refuse with the core's typed +/// diagnostic `host.resource.vca_reach_pairs`, alike, not a panic. +/// +/// Test value: red if VCA automation slips past the reach-pair cap on either host, panics when +/// one pair set is over it, or refuses a session at the cap that the existing caps admit. +#[test] +fn the_reach_pair_cap_bounds_vca_automation_alike_on_both_hosts() { + let automated = |tracks: usize| { + let mut model = bound_model(tracks, 256); + model.automation.extend([ + vca_flat_ride("v255", -6.0), + vca_mute_steps("v255", 0.0, &[(1_000, 1.0)]), + ]); + model + }; + let at_the_cap = automated(64); + assert_eq!( + at_the_cap.vca_reach_pairs(u64::MAX), + host_core::MAXIMUM_VCA_REACH_PAIRS + ); + let (code, diagnostics) = c_abi_compile(&at_the_cap); + assert_eq!( + code, + C_RESULT_OK, + "{}", + String::from_utf8_lossy(&diagnostics) + ); + assert_eq!(browser_boot_refusal(&at_the_cap), None); + let over = automated(65); + let (code, c_diagnostics) = c_abi_compile(&over); + assert_ne!(code, C_RESULT_OK); + assert_eq!( + browser_boot_refusal(&over).expect("the browser refuses"), + c_diagnostics, + "one refusal" + ); + assert_eq!( + c_diagnostics, + b"host.resource.vca_reach_pairs\t$\n".to_vec() + ); +} From 69c156fdd826b2e734efa71f6175600311bbe20c Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 17:02:37 +0000 Subject: [PATCH 29/36] Flag any rebuild that carries an automated following send across a live send move (#1654, root ruling B19-R8) Control plane only. The send-only conservative term now marks a transaction rebuild TransitionFallback when it carries a following route into a following route while a live send move may still ramp, and it counts the transaction's own ramped send records before the decision. Render can cut such a move at the carry (TBD54 removes the cut and the flag). Doc text on all five surfaces; gates for an unrelated rebuild and a same-transaction move on the C ABI and both hosts. Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 57 ++++++ ...ved-send-row-exactly-across-a-live-move.md | 24 ++- crates/capi/include/miso_engine_v1.h | 4 +- crates/capi/src/abi.rs | 5 +- crates/capi/src/runtime/live_tests.rs | 165 ++++++++++++++++-- crates/control-plane/src/control.rs | 37 ++-- crates/host-core/src/prepare.rs | 43 ++++- docs/C_ABI_V1_QUALIFICATION.md | 4 +- hosts/host-web/tests/control_parity.rs | 158 +++++++++++++++-- .../miso-engine-v1-audio-worklet-host.d.ts | 5 +- sdk/src/browser/shipped-host.d.ts | 5 +- 11 files changed, 444 insertions(+), 63 deletions(-) diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 270151249..f60e0a807 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -927,3 +927,60 @@ Sizes (x86-64, measured): `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 1,072 -> 952; `GraphLi Charges read `size_of`. Realtime policy: 171 marked regions in 35 files (unchanged). `scripts/check-graph-policy.sh`: PASS. `check-capi-abi.sh`, `check-sdk-generated.sh`, `check-sdk-types.sh`: pass. + +## Decision record: B19-R8 (batch 19) + +Root ruling B19-R8 after the B19-R7 split verification FAIL (MAJOR-1: any rebuild cuts a live send +move on an automated following send to `J` at the at-once branch `has_dropped || !any_placed` of +`place_carried`, flagged `EXACT`; MAJOR-2: a send move and a row removal in one transaction, flagged +`EXACT` because the flag read the window before the commit folded the transaction's own records). +Control plane only: no render edit, nothing under `crates/engine/src/realtime/` or in +`crates/graph/src/runtime.rs`, no row class or table changed (no SDK regeneration). + +- **The term (`crates/control-plane/src/control.rs`).** A transaction rebuild completes + `TransitionFallback` when `follow_into_six_word` (#1713, unchanged) or when + `follow_into_following && (follow_retarget_ramp > 0 || window.may_ramp(watermark, render_clock))`. + `follow_into_following` is the new join bit + `PlanStateInventory::carries_a_following_route_into_a_following_route` (`crates/host-core/src/ + prepare.rs`: some carried route follows in both plans, seven-word to seven-word); it includes + B19-R7's `carries_a_following_route_that_loses_a_curve`, which now only selects the landing ramp + folded into the window. `follow_retarget_ramp` is the transaction's own join records' longest + ramp: a rebuild delivers its live send values as join records, so this counts MAJOR-2's own move + before the decision (the window still folds them after the commit, as before). +- **Over-report (stated plainly).** Every transaction rebuild that carries an automated following + send is flagged while any live send move of the session may still ramp, or when the rebuild + itself writes a send record with a ramp: whether or not render cuts a move, which column the move + is on, or which send. A fully applied move whose window has not yet passed `first_sample + ramp` + by the render clock also flags. Example: R19-5's reposition across a live move keeps the move's + length bit for bit and is now flagged. A rebuild with no live send move in the window and no + ramped send record of its own completes `EXACT`. TBD54 removes the cut and the flag. +- **Doc (beside #1713's, one wording on the five surfaces):** `crates/capi/src/abi.rs`, + `crates/capi/include/miso_engine_v1.h`, `docs/C_ABI_V1_QUALIFICATION.md`, + `hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts`, `sdk/src/browser/shipped-host.d.ts`: + "It also reports any rebuild that carries an automated following send while a live send move may + still ramp (conservative; TBD54 removes it)." + +Gates (all green; red with the change reverted once): +- C ABI: `an_unrelated_rebuild_across_a_live_send_move_on_a_riding_send_is_flagged_on_the_c_abi` + (MAJOR-1: a direct-route-only rebuild with the riding send's gain offset or `matrix_lr` move in + flight: `TRANSITION_FALLBACK`, count 1; the move long finished: `EXACT`). Red on revert: flags + 1 (`EXACT`) where 2 was expected. +- C ABI: `a_send_move_and_a_row_removal_in_one_transaction_are_flagged_on_the_c_abi` (MAJOR-2: one + transaction, `gain_db` over 4,000 + upsert `rl` + remove `ll`: `TRANSITION_FALLBACK`; the rows + alone: `EXACT`). Red with only the own-ramp operand removed: flags 1. +- Both hosts: `a_rebuild_carrying_a_following_send_across_a_live_send_move_is_flagged_alike_on_both_hosts` + (MAJOR-1, the move long finished, MAJOR-2, MAJOR-2 without the move; one watermark, counts, both + hosts bit for bit). Red on revert at MAJOR-1; red with only the own-ramp operand removed at + MAJOR-2. +- Host-core: `the_join_flags_a_following_route_that_loses_a_curve` also asserts the new bit (every + seven-to-seven carry, not seven-to-six or six-to-seven). Red with the bit forced off. +- No live send move stays `EXACT`: gate (e) of + `send_row_edits_rebuild_or_commit_as_the_rows_rule_says_on_the_c_abi` and gate 7 of + `send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts`, unchanged; the B19-R7 + "the move ended" cases. +- Changed expectations (the over-report): MAJOR-C of both B19-R7 gates (the second, unrelated + rebuild while the move still ramps) is now `TRANSITION_FALLBACK`, count 2; R19-5 in + `a_live_send_move_in_flight_at_a_reposition_keeps_its_length_on_the_c_abi` and in + `send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts` is now + `TRANSITION_FALLBACK`, count 1 (their bit assertions are unchanged). The other B19-R7 flagged + gates stay as they were. diff --git a/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md b/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md index cb7db16fd..e3ee62dd7 100644 --- a/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md +++ b/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md @@ -10,7 +10,10 @@ When a rebuild removes a send's route-row automation (or its source strip's mute send stays a seven-word route and a live send move (a hand gain offset or an un-automated row edit) is still ramping on that column, the move keeps its own length and the column then lands on the new static value over the removed row's own length. The revision's watermark reports `OUTCOME_EXACT`. This -issue deletes the conservative flag of B19-R7. +issue deletes the conservative flag of B19-R7 and B19-R8. + +Any rebuild that carries an automated following send must also keep a live send move on it: the move +keeps its own length, whatever else the rebuild changes. ## Context @@ -18,6 +21,14 @@ issue deletes the conservative flag of B19-R7. source-mute curve lands the column at once over the removed row's `J`. That can cut a live send move in flight, so the control plane marks such a rebuild `CandidateOutcome::TransitionFallback` while a live send move may still ramp (send-only, conservative; the doc text sits beside #1713's). +- MAJOR-1 of the B19-R7 split verification (root ruling B19-R8): any rebuild, an unrelated one too + (a rebuild that edits only the direct route `eq0-main`, an added muted send), cuts a live send move + on an automated following send to `J`. The cut is the at-once branch of `place_carried` in + `crates/graph/src/runtime.rs` (`has_dropped || !any_placed`): a carried column that composes with no + placed value lands at once, so a column with a held ride and a hand move in flight lands over `J` + at the swap. B19-R8 widens the control-plane flag to every transaction rebuild that carries a + following route in both plans while a live send move may still ramp, the transaction's own join + records with a ramp included (MAJOR-2). The exact fix here removes both the cut and that flag. - Two exact attempts failed review (U2 folds 1 and 2): an "owed landing" held per cursor leaked its length to later jumps; held per column, it was dropped by a further rebuild (`adopt` reset it, MAJOR-C) and broken by a seek (its time was a node time computed from a render-time remaining, and a @@ -33,7 +44,16 @@ issue deletes the conservative flag of B19-R7. - G1. The batch 19 MAJOR-A, MAJOR-C (two rebuilds) and MAJOR-D (seek inside, forward and back) setups: bit-equal to the uninterrupted engine until the move's end, then to a fresh plan; `OUTCOME_EXACT`; both hosts; host-core at Simd8 and Simd4 and the scalar oracle. -- G2. The B19-R7 flag and its doc text are deleted; its gates are replaced by G1. +- G1b. MAJOR-1: on a riding send with a live hand move (gain offset or `matrix_lr`) in flight, an + unrelated rebuild keeps the move bit-equal to the never-rebuilt twin until its end, then a fresh + plan; `OUTCOME_EXACT`; both hosts. The same for MAJOR-2 (one transaction: a `gain_db` move over + 4,000, an upsert of one matrix row and the removal of another). +- G2. The B19-R7/B19-R8 flag (`PlanStateInventory::carries_a_following_route_into_a_following_route`, + `SendMoveWindow`) and its doc text on the five surfaces are deleted; its gates + (`an_unrelated_rebuild_across_a_live_send_move_on_a_riding_send_is_flagged_on_the_c_abi`, + `a_send_move_and_a_row_removal_in_one_transaction_are_flagged_on_the_c_abi`, + `a_rebuild_carrying_a_following_send_across_a_live_send_move_is_flagged_alike_on_both_hosts`, and + the flagged expectations of the B19-R7 gates) are replaced by G1 and G1b. - G3. Allocation-free render; no new panic path. ## Dependencies diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index fd6e3dcaf..8c2e7b165 100644 --- a/crates/capi/include/miso_engine_v1.h +++ b/crates/capi/include/miso_engine_v1.h @@ -182,8 +182,8 @@ extern "C" { * MISO_ENGINE_V1_OUTCOME_TRANSITION_FALLBACK reports a warm successor's fallback to its * transition, 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; #1713 removes it). It also - * reports a rebuild that carries a send into one that no longer hears one of its stored curves - * while a live send move may still ramp (conservative; TBD54 removes it). The host + * reports any rebuild that carries an automated following send while a live send move may still + * ramp (conservative; TBD54 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 4f9b9cc75..914b15729 100644 --- a/crates/capi/src/abi.rs +++ b/crates/capi/src/abi.rs @@ -95,9 +95,8 @@ 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 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; -/// #1713 removes it). It also reports a rebuild that carries a send into one that no longer hears -/// one of its stored curves while a live send move may still ramp (conservative; TBD54 removes -/// it). +/// #1713 removes it). It also reports any rebuild that carries an automated following send while +/// a live send move may still ramp (conservative; TBD54 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 0fbef693b..9edbe67cd 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -12166,9 +12166,9 @@ fn removing_a_send_row_or_its_source_mute_curve_lands_on_the_fresh_plan_on_the_c /// `ll` entry, so the send's left source column loses its last curve and lands at once over /// `SendMatrix` (240), cutting the move there. The rebuild completes `TRANSITION_FALLBACK` /// (count 1). Then: nothing (MAJOR-A); a second rebuild that edits only the direct route -/// (MAJOR-C: `EXACT`, it loses no curve); or a session seek inside the move, forward or back -/// (MAJOR-D). From the first block boundary after the move's end and the landing, both planes -/// are a fresh plan of the committed document, session-seeked to the last adoption or seek. The +/// (MAJOR-C: also `TRANSITION_FALLBACK` under B19-R8, the move still ramps; count 2); or a +/// session seek inside the move, forward or back (MAJOR-D). From the first block boundary after +/// the move's end and the landing, both planes are a fresh plan of the committed document, session-seeked to the last adoption or seek. The /// same removal with the move ended long before completes `EXACT` and lands the same way. /// /// Test value: red if the rebuild completes `EXACT` while the cut move ramps (or is flagged with @@ -12258,13 +12258,20 @@ fn removing_a_send_row_across_a_live_send_move_is_flagged_and_lands_on_the_fresh .find(|route| route.id.as_str() == "eq0-main") .expect("the direct route"); direct.gain_db = -3.0; + // B19-R8: the move still ramps, so the unrelated rebuild is flagged too. let (second, after) = rebuild_and_adopt_as( &mut rig, &[SessionEdit::UpsertRoute { route: direct }], BLOCKS, - Some(crate::OUTCOME_EXACT), + Some(fallback), ); assert!(second < start + RAMP, "{label}: the move still runs"); + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + assert_eq!( + watermark.transition_fallback_count, 2, + "{label}: both rebuilds are flagged" + ); live = after; (second, second) } @@ -12308,12 +12315,14 @@ fn removing_a_send_row_across_a_live_send_move_is_flagged_and_lands_on_the_fresh /// #1654 R19-5 on the C ABI. On [`send_ride_document`], a live send move with its own 4,000 /// samples (the hand gain offset `+3`, or a D6 `matrix_lr` edit) is in flight when a rebuild /// changes the gain ride's table (the carried cursor is repositioned). The rebuild completes -/// `EXACT`, with no transition fallback; until the move's end every sample is a twin's that took -/// the same move and no rebuild (the move keeps its length); after the move, the moved row's -/// jump and the grid ramp after it, the plan renders a fresh plan of the committed document. +/// `TRANSITION_FALLBACK` (count 1): B19-R8's conservative term flags every rebuild that carries +/// a following send while a live send move may ramp, though this move is not cut. Until the +/// move's end every sample is a twin's that took the same move and no rebuild (the move keeps +/// its length); after the move, the moved row's jump and the grid ramp after it, the plan renders +/// a fresh plan of the committed document. /// /// Test value: red if a reposition at the swap cuts the live send move (lands the moved table -/// at once) or re-times it, or if the rebuild is flagged. +/// at once) or re-times it. #[test] fn a_live_send_move_in_flight_at_a_reposition_keeps_its_length_on_the_c_abi() { const RAMP: u32 = 4_000; @@ -12339,7 +12348,12 @@ fn a_live_send_move_in_flight_at_a_reposition_keeps_its_length_on_the_c_abi() { automation: changed, }; let writes = rig.current_follow_route_cells(); - let (adoption, live) = rebuild_and_adopt(&mut rig, std::slice::from_ref(&rebuild), 48); + let (adoption, live) = rebuild_and_adopt_as( + &mut rig, + std::slice::from_ref(&rebuild), + 48, + Some(crate::OUTCOME_TRANSITION_FALLBACK), + ); assert_eq!( rig.current_follow_route_cells(), vec![0], @@ -12348,8 +12362,8 @@ fn a_live_send_move_in_flight_at_a_reposition_keeps_its_length_on_the_c_abi() { let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); assert_eq!(result, crate::RESULT_OK); assert_eq!( - watermark.transition_fallback_count, 0, - "{label}: no transition fallback" + watermark.transition_fallback_count, 1, + "{label}: the conservative transition fallback (B19-R8)" ); while (twin.block as usize) * quantum < adoption as usize { twin.step(); @@ -12385,3 +12399,132 @@ fn a_live_send_move_in_flight_at_a_reposition_keeps_its_length_on_the_c_abi() { ); } } + +/// #1654 root ruling B19-R8 (the split verifier's MAJOR-1) on the C ABI. On +/// [`send_ride_document`] (`eq0-b` rides its gain), a live send move over 4,000 samples (the hand +/// gain offset `+3`, or a D6 `matrix_lr` edit) is in flight when a rebuild edits only the direct +/// route `eq0-main`: render may land the riding send's column at once at the swap, so the rebuild +/// completes `TRANSITION_FALLBACK` (count 1). With the move long finished the same rebuild +/// completes `EXACT` (with no hand move at all: gate (e) of +/// `send_row_edits_rebuild_or_commit_as_the_rows_rule_says_on_the_c_abi`). +/// +/// Test value: red if an unrelated rebuild that carries a following send completes `EXACT` while +/// a live send move may still ramp (B19-R7's term flagged only a rebuild that loses a curve), or +/// if it is flagged with no live send move in the window. +#[test] +fn an_unrelated_rebuild_across_a_live_send_move_on_a_riding_send_is_flagged_on_the_c_abi() { + const RAMP: u32 = 4_000; + let document = send_ride_document(); + // (label, the live send move, its lead before the rebuild in blocks, flagged) + for (label, edit, lead, flagged) in [ + ( + "the gain offset in flight", + send_row_edit("gain_db", 3.0, Some(RAMP)), + 2, + true, + ), + ( + "matrix_lr in flight", + send_row_edit("matrix_lr", 0.5, Some(RAMP)), + 2, + true, + ), + ( + "the move long finished", + send_row_edit("gain_db", 3.0, Some(RAMP)), + 40, + false, + ), + ] { + let mut rig = Rig::new(&document); + for _ in 0..4 { + rig.step(); + } + let (start, _) = live_and_render(&mut rig, std::slice::from_ref(&edit), lead); + let now = rig.block * rig.quantum as u64; + assert_eq!( + now < start + u64::from(RAMP), + flagged, + "{label}: the move is in flight at the rebuild" + ); + let mut direct = rig + .model() + .routes + .into_iter() + .find(|route| route.id.as_str() == "eq0-main") + .expect("the direct route"); + direct.gain_db = -3.0; + let outcome = if flagged { + crate::OUTCOME_TRANSITION_FALLBACK + } else { + crate::OUTCOME_EXACT + }; + let _ = rebuild_and_adopt_as( + &mut rig, + &[SessionEdit::UpsertRoute { route: direct }], + 1, + Some(outcome), + ); + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + assert_eq!( + watermark.transition_fallback_count, + u64::from(flagged), + "{label}: the fallback count" + ); + } +} + +/// #1654 root ruling B19-R8 (the split verifier's MAJOR-2) on the C ABI. The send `eq0-b` hears +/// flat `ll` (0.5), `rl` (0.1) and `rr` (0.5) entries. One transaction moves `gain_db` to -6 dB +/// over 4,000 samples, changes the `rl` entry and removes the `ll` entry: it rebuilds, and its +/// own send move (a join record) is cut where the removed row lands at once. The rebuild +/// completes `TRANSITION_FALLBACK` (count 1), though no earlier send move is in the window. The +/// same transaction without the move completes `EXACT`. +/// +/// Test value: red if the flag reads only the send moves of earlier commits (the window folds the +/// transaction's own records after the commit), or if a rebuild with no live send move is flagged. +#[test] +fn a_send_move_and_a_row_removal_in_one_transaction_are_flagged_on_the_c_abi() { + use session::ParameterUnit::Linear; + let document = send_rows_document( + vec![ + route_entry("send-ll", "eq0-b", 3, send_flat(0.5, Linear, false)), + route_entry("send-rl", "eq0-b", 5, send_flat(0.1, Linear, false)), + route_entry("send-rr", "eq0-b", 6, send_flat(0.5, Linear, false)), + ], + |_| {}, + ); + let rows = [ + SessionEdit::UpsertAutomation { + automation: route_entry("send-rl", "eq0-b", 5, send_flat(0.3, Linear, false)), + }, + SessionEdit::RemoveAutomation { + automation_id: StableId::parse("send-ll").expect("automation ID"), + }, + ]; + for (label, moved) in [("the move with the rows", true), ("the rows alone", false)] { + let mut rig = Rig::new(&document); + for _ in 0..4 { + rig.step(); + } + let mut edits = Vec::new(); + if moved { + edits.push(send_row_edit("gain_db", -6.0, Some(4_000))); + } + edits.extend(rows.iter().cloned()); + let outcome = if moved { + crate::OUTCOME_TRANSITION_FALLBACK + } else { + crate::OUTCOME_EXACT + }; + let _ = rebuild_and_adopt_as(&mut rig, &edits, 1, Some(outcome)); + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + assert_eq!( + watermark.transition_fallback_count, + u64::from(moved), + "{label}: the fallback count" + ); + } +} diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 2a8ae80da..641cba2e1 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -2508,7 +2508,9 @@ where // #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(); - // B19-R7 (TBD54 deletes it): the same, for a carried following route that loses a curve. + // B19-R7/B19-R8 (TBD54 deletes them): the same, for any carried following route that + // stays following, and for one that loses a curve (its landing joins the window). + let follow_into_following = inventory.carries_a_following_route_into_a_following_route(); let follow_loses_a_curve = inventory.carries_a_following_route_that_loses_a_curve(); let follow_retarget_ramp = inventory.following_route_retarget_ramp(); let mut candidate_provider = ProviderEpoch::candidate( @@ -2600,23 +2602,26 @@ where // transition fallback, whether or not a column moves at the swap: conservative, it // over-reports. // - // B19-R7 (TBD54 deletes this): a successor whose send join carries a following route - // into a following route that no longer hears one of its row or source mute curves lands - // that column at once over the removed row's length, cutting a live send move still - // ramping there, which render cannot report. Control cannot tell which column lost the - // curve or whether its move is in flight, so such a rebuild completes as the transition - // fallback whenever any live send move may still ramp (read here, before the - // reservation; a watermark read that gives up counts as "may"). A declared - // discontinuity carries nothing. Conservative, it over-reports. + // B19-R8 (TBD54 deletes this; it widens B19-R7's removed-curve term): a successor whose + // send join carries a following route into a following route may land a carried column + // at once at the swap (a removed row or source mute curve, or a column that composes with + // no placed value), cutting a live send move still ramping there, which render cannot + // report. Control cannot tell which column lands at once or whether its move is in + // flight, so such a rebuild completes as the transition fallback whenever any live send + // move may still ramp: the transaction's own join records with a ramp (MAJOR-2: they are + // not yet in the window, which folds them only after the commit), or a move of the + // window (read here, before the reservation; a watermark read that gives up counts as + // "may"). A declared discontinuity carries nothing. Conservative, it over-reports. let transaction = matches!(kind, Rebuild::Transaction(_)); let send_fallback = transaction - && follow_loses_a_curve - && self.send_moves.is_some_and(|window| { - window.may_ramp( - self.shared.watermark.read().ok(), - self.publisher.render_clock(), - ) - }); + && follow_into_following + && (follow_retarget_ramp > 0 + || self.send_moves.is_some_and(|window| { + window.may_ramp( + self.shared.watermark.read().ok(), + self.publisher.render_clock(), + ) + })); // The rebuild's own send moves join the window with the commit: its join's records, and // a removed row's landing. let rebuild_send_ramp = match &kind { diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index ac5dcd10c..174b85aed 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -759,17 +759,31 @@ impl PlanStateInventory { /// (#1654, root ruling B19-R7). `false` for a plan prepared without a predecessor and for a /// declared discontinuity, which carries nothing. /// - /// **Conservative (B19-R7; TBD54 removes it).** Render lands such a column at once over the - /// removed row's own length at the swap, so a live send move still ramping on it is cut. The - /// control plane completes such a rebuild as - /// [`engine::realtime::CandidateOutcome::TransitionFallback`] while a live send move may still - /// ramp. It over-reports: the join reads the route's automated sets, not which column lost - /// the curve or whether that column moves at the swap. + /// Render lands such a column at once over the removed row's own length at the swap; the + /// control plane folds that landing into its send-move window. The transition fallback reads + /// the wider [`Self::carries_a_following_route_into_a_following_route`] (B19-R8). #[must_use] pub fn carries_a_following_route_that_loses_a_curve(&self) -> bool { self.follow_loses_a_curve } + /// Whether this plan's send join carries a following route of the plan it was prepared to + /// displace into a following route of its own (a seven-word route in both plans; #1654, root + /// ruling B19-R8). `false` for a plan prepared without a predecessor and for a declared + /// discontinuity, which carries nothing. + /// + /// **Conservative (B19-R8; TBD54 removes it).** Render may land a carried column at once at + /// the swap (a removed row or source mute curve, or a column that composes with no placed + /// value), so a live send move still ramping on it is cut. The control plane completes such a + /// rebuild as [`engine::realtime::CandidateOutcome::TransitionFallback`] while a live send move + /// may still ramp. It over-reports: the join cannot tell which column lands at once or whether + /// that column moves at the swap. It includes + /// [`Self::carries_a_following_route_that_loses_a_curve`]. + #[must_use] + pub fn carries_a_following_route_into_a_following_route(&self) -> bool { + self.follow_into_following + } + /// The longest ramp of the records this plan's send join writes to its carried following /// routes (a live send move the successor starts at its first block, B19-R7), `0` when it /// writes none. @@ -848,6 +862,9 @@ pub struct PlanStateInventory { /// [`Self::carries_a_following_route_into_a_six_word_route`]). `false` for a plan prepared /// without a predecessor. follow_into_six_word: bool, + /// Whether this plan's send join carries a following route into a following route (B19-R8; + /// see [`Self::carries_a_following_route_into_a_following_route`]). + follow_into_following: bool, /// Whether this plan's send join carries a following route into a following route that hears /// fewer curves (B19-R7; see [`Self::carries_a_following_route_that_loses_a_curve`]). follow_loses_a_curve: bool, @@ -1145,6 +1162,7 @@ impl PlanStateInventory { strip_delays: Box::default(), live_routes: Box::default(), follow_into_six_word: false, + follow_into_following: false, follow_loses_a_curve: false, follow_retarget_ramp: 0, restarted: Box::default(), @@ -1180,6 +1198,7 @@ impl PlanStateInventory { strip_delays: Box::default(), live_routes: Box::default(), follow_into_six_word: false, + follow_into_following: false, follow_loses_a_curve: false, follow_retarget_ramp: 0, restarted: Box::default(), @@ -3745,6 +3764,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( strip_delays: strip_delays.into_boxed_slice(), live_routes: live_routes.into_boxed_slice(), follow_into_six_word: route_carry.follow_into_six_word, + follow_into_following: route_carry.follow_into_following, follow_loses_a_curve: route_carry.follow_loses_a_curve, follow_retarget_ramp: route_carry.follow_retarget_ramp, restarted: restarted.into_boxed_slice(), @@ -6047,6 +6067,9 @@ struct RouteCarry<'a> { /// (#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, + /// Whether some move carries a following route into a following route (B19-R8, + /// [`PlanStateInventory::carries_a_following_route_into_a_following_route`]). + follow_into_following: bool, /// Whether some move carries a following route into a following route that no longer hears /// a row or source mute curve of the predecessor's (B19-R7, /// [`PlanStateInventory::carries_a_following_route_that_loses_a_curve`]). @@ -6098,6 +6121,11 @@ fn resolve_route_carry<'a>( // length, cutting a live send move in flight there. The join flags every such carry; // the control plane completes it as the fallback while a send move may still ramp. carry.follow_loses_a_curve |= follows && successor_follows && loses_a_curve(heard, hears); + // B19-R8 (TBD54 deletes it): any carried following route that stays seven-word may land + // a column at once at the swap, cutting a live send move in flight there, whether or not + // it loses a curve. The control plane completes it as the fallback while a send move may + // still ramp. + carry.follow_into_following |= follows && successor_follows; carry.moves.push(graph::GraphRouteMove { successor: after, predecessor: before, @@ -6994,6 +7022,8 @@ mod tests { }], ); assert_eq!(carry.follow_loses_a_curve, flagged, "{case}"); + // B19-R8: every seven-to-seven carry, whatever its sets. + assert_eq!(carry.follow_into_following, !case.contains("->"), "{case}"); } } @@ -7054,6 +7084,7 @@ mod tests { strip_delays: Box::default(), live_routes: Box::default(), follow_into_six_word: false, + follow_into_following: false, follow_loses_a_curve: false, follow_retarget_ramp: 0, restarted: Box::default(), diff --git a/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index 1d174be95..2aca4c5e3 100644 --- a/docs/C_ABI_V1_QUALIFICATION.md +++ b/docs/C_ABI_V1_QUALIFICATION.md @@ -152,8 +152,8 @@ untouched, and the host retries. In this slice every revision completes as `exac `superseded` and `transition_fallback` producers are #1310, #1397 and #1358. `transition_fallback` also reports 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; #1713 removes it; #1652 U2a fold 2). -It also reports a rebuild that carries a send into one that no longer hears one of its stored curves -while a live send move may still ramp (conservative; TBD54 removes it; #1654, root ruling B19-R7). +It also reports any rebuild that carries an automated following send while a live send move may +still ramp (conservative; TBD54 removes it; #1654, root rulings B19-R7 and B19-R8). 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 diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 9d548bc4e..9bbe21d17 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -6837,11 +6837,14 @@ fn an_output_route_entry_is_refused_alike_on_both_hosts() { /// send's cursors carry verbatim and every block is the never-rebuilt run's, bit for bit; /// (R19-5) a live send move with its own 4,000 samples (the hand offset `+3`, or a D6 /// `matrix_lr` edit) is in flight when a rebuild moves the gain ride's table: until the move's -/// end every block is the run that took the move and no rebuild. Each rebuild completes `EXACT` -/// with no transition fallback on either host, and both hosts render the same bits. +/// end every block is the run that took the move and no rebuild. Gate 7's rebuild completes +/// `EXACT` with no transition fallback on either host; R19-5's completes `TRANSITION_FALLBACK` +/// (count 1), B19-R8's conservative term, though its move is not cut. Both hosts render the same +/// bits. /// /// Test value: red if a continued ride is repositioned at an unrelated swap (a re-timed ride), if a -/// reposition cuts or re-times a live send move, or if either rebuild is flagged. +/// reposition cuts or re-times a live send move, or if gate 7's rebuild (no live send move) is +/// flagged. #[cfg(feature = "test-support")] #[test] fn send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts() { @@ -6874,26 +6877,28 @@ fn send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts() { }; SessionEdit::UpsertAutomation { automation: ride } }; - let exact = |browser: &mut Rig, c_abi: &CAbi, label: &str| { + let outcome = |browser: &mut Rig, c_abi: &CAbi, label: &str, flagged: bool| { let watermark = c_abi.watermark(); assert_eq!( status_watermark(browser), watermark, "{label}: one watermark" ); - assert_eq!( - watermark.2, - capi::OUTCOME_EXACT, - "{label}: the rebuild is exact" - ); + let expected = if flagged { + capi::OUTCOME_TRANSITION_FALLBACK + } else { + capi::OUTCOME_EXACT + }; + assert_eq!(watermark.2, expected, "{label}: the rebuild's outcome"); assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); + let count = u64::from(flagged); assert_eq!( ( browser.host.watermark().transition_fallback_count, c_abi.transition_fallback_count() ), - (0, 0), - "{label}: no transition fallback" + (count, count), + "{label}: the transition fallback count" ); }; // Gate 7. @@ -6916,7 +6921,7 @@ fn send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts() { "gate 7, block {block}: the never-rebuilt run" ); } - exact(&mut browser, &c_abi, "gate 7"); + outcome(&mut browser, &c_abi, "gate 7", false); // R19-5. for (label, edits, offset) in [ ("the gain offset", send_edit(1, 3.0, Some(RAMP)), 3.0), @@ -6944,7 +6949,8 @@ fn send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts() { "{label}, block {block}: the move runs" ); } - exact(&mut browser, &c_abi, label); + // B19-R8: the move may still ramp at the rebuild, so it is flagged. + outcome(&mut browser, &c_abi, label, true); } } @@ -7009,7 +7015,8 @@ fn removing_a_send_row_or_its_source_mute_curve_lands_alike_on_both_hosts() { /// block 6 removes the `ll` entry: the send's left column loses its last curve and lands at once /// over `SendMatrix` (240), cutting the move there. On both hosts the rebuild completes /// `TRANSITION_FALLBACK` (one watermark, count 1). Then: nothing (MAJOR-A), a second rebuild before -/// block 8 that adds a muted, inactive send (MAJOR-C: `EXACT`, it loses no curve), or a session +/// block 8 that adds a muted, inactive send (MAJOR-C: also `TRANSITION_FALLBACK` under B19-R8, the +/// move still ramps; count 2), or a session /// seek before block 7 inside the move, forward or back (MAJOR-D). From the first block boundary /// after the move's end both hosts render, bit for bit, a fresh plan of the committed model fed /// and seeked alike. The same removal before block 24, the move long ended, completes `EXACT`. @@ -7162,14 +7169,135 @@ fn removing_a_send_row_across_a_live_send_move_is_flagged_alike_on_both_hosts() assert_eq!(watermark.2, outcome, "{label}: the removal's outcome"); } if second && block == rebuild + 2 { + // B19-R8: the move still ramps, so the second rebuild is flagged too. + assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); assert_eq!( c_abi.watermark().2, - capi::OUTCOME_EXACT, + capi::OUTCOME_TRANSITION_FALLBACK, "{label}: the second rebuild" ); } timeline += 1; } + assert_eq!( + ( + browser.host.watermark().transition_fallback_count, + c_abi.transition_fallback_count() + ), + { + let count = u64::from(flagged) + u64::from(second); + (count, count) + }, + "{label}: the fallback count" + ); + } +} + +/// #1654 root ruling B19-R8 on both hosts. MAJOR-1: `t1-b` rides its `gain_db` (0 to -12 dB); +/// a hand gain move of `+3` over 4,000 samples (before block 4) is in flight when a rebuild before +/// block 6 adds a muted, inactive send: the rebuild carries the riding send, which render may land +/// at once at the swap, so it completes `TRANSITION_FALLBACK` (one watermark, count 1). The same +/// rebuild before block 44 (the move long finished) completes `EXACT` (with no hand move at all: +/// gate 7 of `send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts`). +/// MAJOR-2: `t1-b` hears flat `ll`, `rl` and `rr` entries; one transaction before block 4 moves +/// `gain_db` to -6 dB over 4,000 samples, changes `rl` and removes `ll`: it completes +/// `TRANSITION_FALLBACK` from its own send move alone; without the move it completes `EXACT`. +/// Every block both hosts render the same bits. +/// +/// Test value: red if either host completes a rebuild that carries a following send `EXACT` while +/// a live send move (an earlier commit's or the transaction's own) may still ramp, if one is +/// flagged with no live send move, or if the hosts disagree. +#[cfg(feature = "test-support")] +#[test] +fn a_rebuild_carrying_a_following_send_across_a_live_send_move_is_flagged_alike_on_both_hosts() { + use session::{AutomationShape, ParameterUnit}; + const RUN: u64 = 48; + const RAMP: u32 = 4_000; + let end = u64::from(QUANTUM) * BLOCKS; + let flat = |value: f32| { + vec![send_piece( + AutomationShape::Linear, + (0, value), + (end, value), + ParameterUnit::Linear, + )] + }; + let mut riding = follow_model(false, false); + riding.automation.push(send_entry( + "t1-b-gain", + 1, + vec![send_piece( + AutomationShape::Linear, + (0, 0.0), + (end, -12.0), + ParameterUnit::Db, + )], + )); + let mut muted_send = t1_b(&mut riding.clone()).clone(); + muted_send.id = id("t2-b"); + muted_send.source = session::RouteSource::Track { + track_id: id(T2.id), + tap: session::SendTap::PreFader, + }; + muted_send.mute = true; + muted_send.follows_mute = false; + let unrelated = vec![SessionEdit::UpsertRoute { route: muted_send }]; + let mut rows = follow_model(false, false); + rows.automation.push(send_entry("t1-b-ll", 3, flat(0.5))); + rows.automation.push(send_entry("t1-b-rl", 5, flat(0.1))); + rows.automation.push(send_entry("t1-b-rr", 6, flat(0.5))); + let row_edits = vec![ + SessionEdit::UpsertAutomation { + automation: send_entry("t1-b-rl", 5, flat(0.3)), + }, + SessionEdit::RemoveAutomation { + automation_id: id("t1-b-ll"), + }, + ]; + let mut same_transaction = send_edit(1, -6.0, Some(RAMP)); + same_transaction.extend(row_edits.iter().cloned()); + let hand = send_edit(1, 3.0, Some(RAMP)); + // (label, model, live move block, rebuild block, rebuild edits, flagged) + for (label, model, live, rebuild, edits, flagged) in [ + ("MAJOR-1", &riding, Some(4), 6, &unrelated, true), + ( + "MAJOR-1, the move long finished", + &riding, + Some(4), + 44, + &unrelated, + false, + ), + ("MAJOR-2", &rows, None, 4, &same_transaction, true), + ("MAJOR-2, no move", &rows, None, 4, &row_edits, false), + ] { + let mut browser = Rig::new(model); + let mut c_abi = CAbi::new(model); + for block in 0..RUN { + if live == Some(block) { + both_commit(&mut browser, &mut c_abi, &hand, EditPath::Live, label); + } + if block == rebuild { + both_commit(&mut browser, &mut c_abi, edits, EditPath::Rebuild, label); + } + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{label}, block {block}: the two hosts agree"); + if block == rebuild { + let watermark = c_abi.watermark(); + assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); + assert_eq!( + status_watermark(&browser), + watermark, + "{label}: one watermark" + ); + let outcome = if flagged { + capi::OUTCOME_TRANSITION_FALLBACK + } else { + capi::OUTCOME_EXACT + }; + assert_eq!(watermark.2, outcome, "{label}: the rebuild's outcome"); + } + } assert_eq!( ( browser.host.watermark().transition_fallback_count, 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 80095f1f3..aca75d684 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,9 +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 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; #1713 removes it). It also reports a rebuild that carries a send into one that no - /// longer hears one of its stored curves while a live send move may still ramp (conservative; - /// TBD54 removes it). + /// swap; #1713 removes it). It also reports any rebuild that carries an automated following + /// send while a live send move may still ramp (conservative; TBD54 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 80095f1f3..aca75d684 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -781,9 +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 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; #1713 removes it). It also reports a rebuild that carries a send into one that no - /// longer hears one of its stored curves while a live send move may still ramp (conservative; - /// TBD54 removes it). + /// swap; #1713 removes it). It also reports any rebuild that carries an automated following + /// send while a live send move may still ramp (conservative; TBD54 removes it). /// @internal readonly appliedOutcome: bigint; /// The module's memory size now: at least the boot size (`MisoAudioWorkletHost.memoryBytes`), From ab5d12fb22bd4bd0a1fb5d5f258908191c231aaa Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 17:02:46 +0000 Subject: [PATCH 30/36] Document the route column's held-value rule as defined behaviour (#1654, root) Co-Authored-By: Claude Opus 5.5 --- ...4-render-stored-send-gain-mute-and-matrix-automation.md | 7 +++++++ ...4-land-a-removed-send-row-exactly-across-a-live-move.md | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index f60e0a807..19c1a6dc7 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -984,3 +984,10 @@ Gates (all green; red with the change reverted once): `send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts` is now `TRANSITION_FALLBACK`, count 1 (their bit assertions are unchanged). The other B19-R7 flagged gates stay as they were. + +## Defined behaviour: held values on a route column (root, batch 19) + +On a route column, a value held by an earlier column jump follows the column's current free time and +never moves earlier (R19-3 (d), T3). It is deterministic and equal to a fresh plan. A single fader cell +differs: there, a later step's jump replaces the ramp and shortens the hold, so the grid resumes after +that step's own length. Unifying the two is phase 2 (TBD54, "Related"). diff --git a/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md b/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md index e3ee62dd7..79ee1ee43 100644 --- a/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md +++ b/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md @@ -59,3 +59,10 @@ keeps its own length, whatever else the rebuild changes. ## Dependencies - #1654 (batch 19), #1713 (the seven-to-six flag, separate). + +## Related + +- A route column and a single fader cell hold values differently: on a route column a value held by an + earlier column jump follows the column's free time and never moves earlier (R19-3 (d), #1654 T3), + while in a fader cell a later step's jump shortens the hold. Unifying the two rules is phase 2 (root, + batch 19); it is not part of this issue's gates. From 2922d916c8698b4092963bef905793db0764fc58 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 17:14:47 +0000 Subject: [PATCH 31/36] Release every cursor's hold when a composed fader lane jumps (#1655 U3 fold 1) On a composed fader lane, a hold or deferral that a lane-level ramp put on every cursor outlived another curve's step, so the lane froze for the rest of that ramp. A new opt-in Composed::with_jump_release shortens each restartable cursor's hold to the emitted jump's completion and clears its deferral, as a single cell's jump does; only fader composed lanes use it, so route columns keep their T3 rule. Gates in K1, host-core (Simd8, Simd4, scalar; live and rebuild) and both hosts, red before. Co-Authored-By: Claude Opus 5.5 --- ...r-stored-vca-offset-and-mute-automation.md | 59 ++++++++ crates/automation/src/compose.rs | 62 ++++++++- crates/automation/src/compose/tests.rs | 62 +++++++++ crates/builtins-compiler/src/lib.rs | 11 +- crates/host-core/src/prepare.rs | 4 +- .../tests/stored_fader_automation.rs | 100 ++++++++++++- .../host-core/tests/stored_vca_automation.rs | 131 ++++++++++++++++++ hosts/host-web/tests/control_parity.rs | 38 ++++- 8 files changed, 453 insertions(+), 14 deletions(-) diff --git a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md index 9051b6cc6..7f513a604 100644 --- a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md +++ b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md @@ -730,3 +730,62 @@ automation (44), builtins-compiler `--features test-support` (94), graph `--feat (159), host-core `--features test-support` (485) and `test-support,control-provider` (613), control-plane `--features test-support` (30), capi lib (220), host-web `--features test-support --test control_parity` (52): all green. Realtime policy: 172 regions in 35 files (unchanged). + +## Decision record: U3 fold 1 (batch 19) + +U3 MAJOR-1 and NIT-1. No edit under `crates/engine/src/realtime/`, no row class or table change (no +SDK regeneration). + +- **Fix (K1, opt-in).** `automation::Composed::with_jump_release` (new flag, copied by `copy_from`): + every jump the output emits (not a seek landing, not a held event) shortens each restartable value + cursor's hold to the jump's completion (`CellState::release_hold_by`) and drops its deferral + (`clear_deferred_jump`), before the `with_held_grid` hold and the column-jump deferral; a cursor + that pulled ahead is replayed first, as for a hold. A jump that emits nothing changes nothing + (#1711 U1 fold 2). It reaches every hold and deferral the lane put on every cursor: the record + hold in `offsets_jump`, the adoption hold and deferral of `settle_adoption`, and the holds + `with_held_grid` propagates, so a composed lane moves as one cell does (V2 departure 2). +- **Who uses it.** Only the fader composed lanes (`FaderCurve::prepared`: `.with_held_grid() + .with_jump_release()`). The composed mute lanes have gate cursors only. The route column + (`crates/graph`, #1654) does not call it and keeps its T3 rule: a held value follows the column's + current free time and never moves earlier. No graph file changed; graph tests and #1654 gates + unchanged. +- **NIT-1.** `follow_route_flags`' doc lines moved back above it; `follow_vca_mute_cells` keeps its + own doc. + +**Departures.** +1. The host-core live gate compares from the step's jump's end (3,240), not from 0. Before the step + the composed lane and one cell over the sum differ: a record's ramp target composes each curve + toward the ramp's completion clipped at that curve's own next discontinuity, where one cell clips + the sum at the lane's next discontinuity (`FaderComposite::toward` against `curve_toward`). That + rule is outside this finding (phase-2 placeholder in the fold report); the jump's end equalizes + the gains, and the defect window (3,328 to 9,024) lies after it. +2. The both-host gate runs at the native width only (control_parity's rule, V5); the two bank widths + are the host-core twin's. + +**Gates** (red before: the gate with `.with_jump_release()` removed from `FaderCurve::prepared`, or +from the K1 probe; green after): +- K1: `compose::tests::a_step_replaces_a_hold_the_lane_put_on_every_cursor_as_one_cell_does` (every + cursor held to 2,999, then also deferred to 3,000; the composed retargets equal one cell over the + summed curve with the same hold and deferral). Red: `(2944, Grid)` after the step instead of + `(1472, Grid)`. +- host-core live: `stored_vca_automation::classifier:: + a_vca_step_inside_a_hand_moves_ramp_lets_the_own_ride_move_as_one_cell` (own ride on a 2^-12 dB + lattice, VCA step at 3,000, hand VCA move to +3 over 8,000 at 1,024; Simd8, Simd4, scalar, live and + between render calls; same classifier records as the summed single-cell session). Red: first + differing sample 3,328 (Simd8). +- host-core rebuild twin: `stored_fader_automation:: + a_table_step_inside_a_deferred_moves_ramp_releases_every_cursor_of_a_composed_lane` (Simd8 and + Simd4; track 0 also in an automated VCA riding 0 to -6 dB). Red: first differing sample 4,544. +- both hosts: `control_parity:: + a_table_step_inside_a_deferred_moves_ramp_releases_a_composed_lane_alike_on_both_hosts` (the + #1711 body over a session with the VCA ride; four rates). Red: block 39 against the reference at + 44.1 kHz. The single-cell test now calls the shared body `table_step_inside_a_deferred_move`. + +Commands: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` and no-feature +`cargo check --locked --all-targets` on automation, builtins-compiler, host-core, graph, host-web; +tests automation (all features), builtins-compiler `--features test-support` (76 lib), graph +`--features test-support` (149 lib, unchanged), host-core `--features test-support` +`stored_vca_automation` (7; 13 with `control-provider`), `stored_fader_automation` (37), +`stored_mute_automation` (13), `route_mute` (26), `successor_swap` (140), control-plane +`--features test-support` (30), capi lib (220), host-web `--features test-support --test +control_parity` (54): all green. Realtime policy: 172 regions in 35 files (unchanged). diff --git a/crates/automation/src/compose.rs b/crates/automation/src/compose.rs index b34487464..4db41239b 100644 --- a/crates/automation/src/compose.rs +++ b/crates/automation/src/compose.rs @@ -220,6 +220,10 @@ pub struct Composed { /// [`Self::with_held_grid`]: every output retarget holds each value cursor's grid inside the /// retarget's ramp, as one cell holds its grid inside its own ramp (#1655 V2, R19-2). held_grid: bool, + /// [`Self::with_jump_release`]: every jump the output emits replaces the lane's ramp, so it + /// shortens each value cursor's hold to the jump's completion and drops its deferral, as one + /// cell's jump does (#1655 U3 fold 1). + jump_release: bool, } impl Composed { @@ -278,6 +282,7 @@ impl Composed { completion, block: None, held_grid: false, + jump_release: false, }) } @@ -296,6 +301,23 @@ impl Composed { self.held_grid = true; self } + + /// The output with the one-cell jump rule on the holds and deferrals its lane put on every + /// cursor (#1655 U3 fold 1: a member fader channel, with [`Self::with_held_grid`]). A lane's + /// ramp (a record's or an adoption's, reported on every cursor with + /// [`CellState::hold_until`] and [`Self::defer_jump`], or a retarget that `with_held_grid` + /// reported) belongs to the composed output; one cell's discontinuity jump that emits + /// replaces its ramp in flight and drops its deferral. So every jump the output emits (not a + /// seek landing, not a held event) shortens each restartable value cursor's hold to the + /// jump's completion ([`CellState::release_hold_by`]) and drops its deferral + /// ([`CellState::clear_deferred_jump`]); a jump that emits nothing replaces no ramp and + /// changes nothing (#1711 U1 fold 2). Without it (a route column) a held value follows the + /// column's current free time and never moves earlier (#1654 T3). Control side. + #[must_use] + pub fn with_jump_release(mut self) -> Self { + self.jump_release = true; + self + } } // REALTIME_POLICY_BEGIN @@ -474,6 +496,7 @@ impl Composed { self.completion = other.completion; self.block = other.block; self.held_grid = other.held_grid; + self.jump_release = other.jump_release; true } @@ -558,10 +581,24 @@ impl Composed { let hold = event .filter(|event| self.held_grid && !event.held && event.ramp > 0) .map(|event| crate::completion_of(self.completion, time, event.ramp)); + // `with_jump_release`: the completion of the jump this offset emits, which replaces + // the lane's ramp on every value cursor. + let release = event + .filter(|event| { + self.jump_release && !landing && !event.held && event.kind == EventKind::Jump + }) + .map(|event| crate::completion_of(self.completion, time, event.ramp)); let completion = self.completion; let short = |state: &CellState, hold: i64| { state.free_after(completion).is_none_or(|free| free <= hold) }; + // Whether the release changes `state`: a hold past `at` or a pending deferral. + let releases = |state: &CellState, at: i64| { + let mut released = *state; + released.release_hold_by(at); + state.deferred_jump().is_some() + || released.free_after(completion) != state.free_after(completion) + }; let next = offset.saturating_add(1); for cursor in self.cursors.iter_mut() { cursor.shown = cursor.heard; @@ -575,8 +612,23 @@ impl Composed { } else { cursor.before.deferred_jump() }; + let released = release.filter(|&at| { + value + && program + .cell(cursor.cell as usize) + .is_some_and(|cell| !cell.no_restart) + && releases( + if cursor.consumed { + &cursor.state + } else { + &cursor.before + }, + at, + ) + }); let extend = free.filter(|&free| { !column_jump + && released.is_none() && !cursor.moved && pending.is_some_and(|at| at > time && at < free) }); @@ -594,13 +646,17 @@ impl Composed { hold, ) }); - if value && (defer.is_some() || held.is_some()) { + if value && (defer.is_some() || held.is_some() || released.is_some()) { if !cursor.consumed { // The cursor pulled past this offset before the column ramp changed: - // replay its pull up to here (it gave no event there), then defer (or - // hold) and pull again. + // replay its pull up to here (it gave no event there), then release, + // defer (or hold) and pull again. replay(cursor, program, &span, mode, next); } + if let Some(at) = released { + cursor.state.release_hold_by(at); + cursor.state.clear_deferred_jump(); + } if let Some(hold) = held && short(&cursor.state, hold) { diff --git a/crates/automation/src/compose/tests.rs b/crates/automation/src/compose/tests.rs index e17436cc1..d8d6000dd 100644 --- a/crates/automation/src/compose/tests.rs +++ b/crates/automation/src/compose/tests.rs @@ -627,3 +627,65 @@ fn an_unchanged_value_jump_keeps_the_gate_deferral() { [(1_000, EventKind::Jump, 480), (1_480, EventKind::Jump, 256)] ); } + +/// #1655 U3 fold 1 (MAJOR-1), `with_jump_release`: a hold or a deferral the lane put on every +/// cursor (a record's ramp, an adoption's ramp and its deferred jump) belongs to the composed +/// output, and a step of one curve that emits a jump replaces it, as one cell's jump replaces its +/// ramp in flight. Every cursor holds to 2,999 (and, second, defers its jump to 3,000); the step +/// at 1,000 jumps over 480. The composed lane's retargets are those of one cell over the summed +/// curve with the same hold and deferral: the riding curve's grid moves the lane again from +/// 1,472. +/// +/// Red if the jump leaves the lane's hold or deferral on the riding cursor (it then freezes the +/// lane to 2,944 with the hold, to 3,000 with the deferral). +#[test] +fn a_step_replaces_a_hold_the_lane_put_on_every_cursor_as_one_cell_does() { + let ride = [seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + let step = [ + seg(0, 1_000, -6.0, -6.0, Shape::Step), + seg(1_000, 9_000, -12.0, -12.0, Shape::Step), + ]; + let sum = [ + seg(0, 1_000, -6.0, -9.0, Shape::Linear), + seg(1_000, 8_000, -15.0, -36.0, Shape::Linear), + seg(8_000, 9_000, -36.0, -36.0, Shape::Step), + ]; + let program = ProgramBuilder::build(&[fader_cell(&ride), fader_cell(&step), fader_cell(&sum)]) + .expect("program"); + let span = NodeSpan::new(0, None, 4_000).expect("span"); + for defer in [None, Some(3_000)] { + let mut reference = seeded(&program, 2); + reference.hold_until(2_999); + if let Some(at) = defer { + reference.defer_jump(at); + } + let cell = program.cell(2).expect("cell"); + let expected: Vec<_> = CellEvents::new(cell, &mut reference, span, SeekMode::Exact, 480) + .filter(|event| !event.held) + .map(|event| (event.offset, event.kind, event.ramp)) + .collect(); + let mut composed = Composed::::new( + &program, + &[(0, CursorKind::Value), (1, CursorKind::Value)], + Completion::End, + ) + .expect("bind") + .with_held_grid() + .with_jump_release(); + composed.seed(summed); + for cursor in composed.cursors_mut() { + cursor.set_jump(480); + cursor.state_mut().hold_until(2_999); + } + if let Some(at) = defer { + composed.defer_jump(at); + } + let (actual, _) = retargets(&program, &mut composed, summed, 1_000); + assert_eq!( + actual.get(..2), + Some(&[(1_000, EventKind::Jump, 480), (1_472, EventKind::Grid, 64)][..]), + "{defer:?}" + ); + assert_eq!(actual, expected, "{defer:?}"); + } +} diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index fc587ba06..1c10fde5c 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -830,9 +830,11 @@ enum FaderCurve { /// D2's order: `S` first, then the curves in cursor order), `own` the own curve's value or the /// member's static `own_db` (D3). The output keeps one cell's hold rule /// ([`automation::Composed::with_held_grid`]), so a step of one curve keeps its jump length while -/// another rides; every other rule of the single cell (the held-ramp obligation, the R17-10 -/// release, the adoption and its deferred jump, #1711) acts on it through the same driver, -/// applied to every cursor. +/// another rides, and one cell's jump rule ([`automation::Composed::with_jump_release`], #1655 +/// U3 fold 1), so a jump the lane emits replaces the hold and the deferral the lane put on every +/// cursor; every other rule of the single cell (the held-ramp obligation, the R17-10 release, the +/// adoption and its deferred jump, #1711) acts on it through the same driver, applied to every +/// cursor. #[derive(Debug)] struct FaderComposite { walk: automation::Composed, @@ -891,7 +893,8 @@ impl FaderCurve { .collect(); let mut walk = automation::Composed::new(program, &cells, controls::FADER_CELL_RULE.completion)? - .with_held_grid(); + .with_held_grid() + .with_jump_release(); // The prepared value, composed once inside preparation's `CanonicalFpEnv` (D2, R17-7). walk.seed_target(cell.fader_db); Ok(Self::Composed(Box::new(FaderComposite { diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index b3749e600..946022764 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -4474,8 +4474,6 @@ impl SendRecord { } } -/// The flags word of a following route (#1652 D1, #1654 D2): the route's `mute` and -/// `follows_mute`, the source lanes' `terms` and the plan's automated set (bits 4-11). /// The VCA mute curves of strip `source`'s lanes that a following route's op ORs into its follow /// term at `a(tap)` (#1655 D4, R19-2): per source lane `[left, right]`, the program cell index of /// each reaching VCA's automated `mute` lane (`PreparedBuiltinsSession::vca_cells`, row 1, the @@ -4521,6 +4519,8 @@ pub(crate) fn follow_vca_mute_cells

( Some(lanes) } +/// The flags word of a following route (#1652 D1, #1654 D2): the route's `mute` and +/// `follows_mute`, the source lanes' `terms` and the plan's automated set (bits 4-11). pub(crate) fn follow_route_flags( route: &session::Route, terms: [bool; 2], diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index f14c993a0..6934cff6d 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -3303,20 +3303,69 @@ type LiveMute = (usize, (bool, u32)); /// live hand `mutes` (both given to the rebuilt run and to the fresh reference plan). #[cfg(feature = "test-support")] fn rebuilt_case( + backend: graph_compiler::Backend, + case: MovedTargetMove, + heard: usize, + tables: [Vec; 3], + moves: (&[LiveMove], &[LiveMute]), + from: u64, +) -> Vec { + rebuilt_case_over(backend, case, heard, tables, moves, from, None) +} + +/// `model` with track 0 also in VCA `vca-ride` (0 dB), whose `offset_db` rides `ride` on both +/// lanes (#1655): track 0's fader channels are then composed lanes over its own curve and the +/// VCA's. +#[cfg(feature = "test-support")] +fn with_vca_ride(mut model: SessionModel, ride: &[AutomationSegment]) -> SessionModel { + let vca = id("vca-ride"); + model.vcas.push(session::Vca { + id: vca.clone(), + fader: session::DualMonoFader { + left_db: 0.0, + right_db: 0.0, + left_mute: false, + right_mute: false, + }, + members: vec![model.tracks[0].id.clone()], + }); + model.automation.push(Automation { + id: id("vca-ride-offset"), + target: ControlAddress { + owner: Owner::Vca(vca), + section: Section::Own, + control: 1, + lane: ParameterChannel::Both, + }, + segments: ride.to_vec(), + offset: session::AutomationOffset::default(), + }); + model +} + +/// [`rebuilt_case`], with every model's track 0 also reached by the VCA ride `ride` when given +/// ([`with_vca_ride`]). +#[cfg(feature = "test-support")] +fn rebuilt_case_over( backend: graph_compiler::Backend, (what, hand, vca): MovedTargetMove, heard: usize, [flat, table, fresh]: [Vec; 3], (later, mutes): (&[LiveMove], &[LiveMute]), from: u64, + ride: Option<&[AutomationSegment]>, ) -> Vec { const AFTER: usize = 44; let swap = MOVE_BLOCK + heard; let blocks = (swap + AFTER).max(later.iter().map(|(at, _)| at + 20).max().unwrap_or(0)); - let a = move_model(flat.clone(), 0.0, vca.map(|vca| vca.0)); - let committed = move_model(flat, hand, vca.map(|vca| vca.1)); - let next = move_model(table.clone(), hand, vca.map(|vca| vca.1)); - let reference = move_model(fresh, 0.0, vca.map(|vca| vca.0)); + let ridden = |model: SessionModel| match ride { + Some(ride) => with_vca_ride(model, ride), + None => model, + }; + let a = ridden(move_model(flat.clone(), 0.0, vca.map(|vca| vca.0))); + let committed = ridden(move_model(flat, hand, vca.map(|vca| vca.1))); + let next = ridden(move_model(table.clone(), hand, vca.map(|vca| vca.1))); + let reference = ridden(move_model(fresh, 0.0, vca.map(|vca| vca.0))); let moves: Vec<_> = core::iter::once(MOVE) .chain(later.iter().copied()) .collect(); @@ -3473,6 +3522,49 @@ fn a_table_step_inside_a_deferred_moves_ramp_drops_the_deferral() { } } +/// #1655 U3 fold 1 (MAJOR-1): the composed twin of +/// [`a_table_step_inside_a_deferred_moves_ramp_drops_the_deferral`], at both bank widths. Track +/// 0 is also in VCA `vca-ride`, whose `offset_db` rides linearly from 0 to -6 dB over 48,000 +/// samples (the same in every plan), so its fader channels are composed lanes over its own curve +/// and the VCA's. The rebuild moves the own table; the adoption holds every cursor through the +/// move's ramp and defers every cursor's jump to 4,512; the own table's step at 2,000 replaces +/// that ramp on the whole lane, as one cell's step does. Every sample is bit-equal to the fresh +/// plan whose own table is -3 dB before 2,000, given the same move and the same VCA ride. +/// +/// Test value: red if a composed lane's jump drops only the stepping cursor's deferral and hold +/// (the VCA cursor then freezes the lane until 4,512 while the fresh plan follows the ride). +#[cfg(feature = "test-support")] +#[test] +fn a_table_step_inside_a_deferred_moves_ramp_releases_every_cursor_of_a_composed_lane() { + let stepped = |first: f32| { + let mut segments = steps_of(&[(0, 2_000, first)]); + segments.push(segment(AutomationShape::Linear, 2_000, 8_000, -8.0, -14.0)); + segments.extend(steps_of(&[(8_000, RIDE_FRAMES, -14.0)])); + segments + }; + let ride = [segment(AutomationShape::Linear, 0, RIDE_FRAMES, 0.0, -6.0)]; + let [flat, ..] = moved_target_tables(); + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + for case in MOVED_TARGET_MOVES { + for heard in [1, 4] { + rebuilt_case_over( + backend, + case, + heard, + [flat.clone(), stepped(-5.0), stepped(-3.0)], + (&[], &[]), + 0, + Some(&ride), + ); + } + } + } +} + /// #1711 U1 fold 1 (MINOR-2) on host-core, at both bank widths. The G2 setup heard 12 blocks /// (swap at 2,048, the deferred jump due at 4,512), with a live hand mute at 2,560 and an unmute /// at 3,072 (inside the deferral) or at 5,120 (after it), each ramp 200 samples or exact. The diff --git a/crates/host-core/tests/stored_vca_automation.rs b/crates/host-core/tests/stored_vca_automation.rs index 44336c2ec..d368cdb29 100644 --- a/crates/host-core/tests/stored_vca_automation.rs +++ b/crates/host-core/tests/stored_vca_automation.rs @@ -1558,4 +1558,135 @@ mod classifier { ); assert_heard_as_fresh(¤t, &next, &records); } + + /// #1655 U3 fold 1 (MAJOR-1), live: `t0` rides its own fader (flat 0 dB to 3,328, then + /// linear to -24 dB over 98,304 samples: every grid target is a multiple of 2^-12 dB) and the + /// automated offset of `va` steps from 0 to -6 dB at 3,000 (`J` = 240). At 1,024 a hand move + /// of `va` to +3 dB over 8,000 samples writes each lane's `S` with its own ramp, which every + /// cursor of the composed lane holds through; the step's jump at 3,000 replaces that ramp on + /// the whole lane, as one cell's jump does, so the lane follows the own ride from 3,328 on. + /// From the step's jump's end (3,240) every sample equals the session whose `t0` rides the + /// summed curve as its own table under a static `va` given the same hand move (one cell), on + /// every backend, live and between render calls; the classifier gives both the same records. + /// Before 3,000 the two differ: the record's ramp aims at each curve toward its completion, + /// clipped at that curve's own next discontinuity (the VCA step), where one cell clips the + /// sum at the lane's; the jump's end makes the gains equal. + /// + /// Test value: red if a jump of one curve of a composed lane leaves the hold the record put + /// on every cursor in place (the own cursor's grid then freezes the lane to 9,024). + #[test] + fn a_vca_step_inside_a_hand_moves_ramp_lets_the_own_ride_move_as_one_cell() { + const FROM: usize = 3_000 + J as usize; + let ride_from = 3_328_u64; + let ride_end = ride_from + 98_304; + let session = |composed: bool| { + let mut model = plain(1, 128); + let t0 = model.tracks[0].id.clone(); + model.vcas = vec![vca("va", vec![t0.clone()])]; + let own = if composed { + vec![ + piece( + AutomationShape::Step, + (0, ride_from), + (0.0, 0.0), + ParameterUnit::Db, + ), + piece( + AutomationShape::Linear, + (ride_from, ride_end), + (0.0, -24.0), + ParameterUnit::Db, + ), + ] + } else { + vec![ + piece( + AutomationShape::Step, + (0, 3_000), + (0.0, 0.0), + ParameterUnit::Db, + ), + piece( + AutomationShape::Step, + (3_000, ride_from), + (-6.0, -6.0), + ParameterUnit::Db, + ), + piece( + AutomationShape::Linear, + (ride_from, ride_end), + (-6.0, -30.0), + ParameterUnit::Db, + ), + ] + }; + model.automation = vec![entry_of( + "t0-ride", + Owner::Strip(t0), + Section::Builtins, + FADER_DB, + own, + )]; + if composed { + model.automation.push(entry_of( + "va-ride", + Owner::Vca(id("va")), + Section::Own, + VCA_OFFSET, + steps_of(0.0, &[(3_000, -6.0)], ParameterUnit::Db), + )); + } + normalized(&model) + }; + let mut ramp = EditRamps::new(); + ramp.set( + &EditRampTarget::Vca(id("va")), + EditRampRow::VcaFader, + Some(8_000), + ); + let row = address(Owner::Vca(id("va")), Section::Own, VCA_OFFSET); + let pair = |composed: bool| { + let current = session(composed); + let next = edited(¤t, |next| { + controls::set(next, &row, 3.0, Some(8_000), RATE).expect("a hand move"); + }); + let records = classify(¤t, &next, &ramp).expect("live"); + (current, records) + }; + let ((composed, records), (single, single_records)) = (pair(true), pair(false)); + let t0 = composed.tracks[0].id.as_str().to_owned(); + let s = f64::from(3.0_f32); + assert_eq!( + records, + vec![(t0, Vec::new(), [Some(offsets(s, 8_000, None)); 2])] + ); + assert_eq!(records, single_records, "one cell's records"); + for backend in backends() { + for between in [false, true] { + let what = format!("{backend:?} between {between}"); + let run = |model: &SessionModel| { + let mut rig = Rig::new(model, backend, between); + rig.blocks(8); + write(&mut rig, &records); + rig.blocks(92); + assert_eq!(rig.allocations, (0, 0), "{what}: the render thread"); + rig.out + }; + let (heard, expected) = (run(&composed), run(&single)); + for lane in 0..2 { + let first = (FROM..heard[lane].len()) + .find(|&at| heard[lane][at].to_bits() != expected[lane][at].to_bits()); + assert_eq!( + first, None, + "{what}: lane {lane}, first sample that differs" + ); + } + assert_ne!( + heard[0][3_400].to_bits(), + heard[0][9_000].to_bits(), + "{what}: the own ride moves the lane inside the hand move" + ); + } + } + } } diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 18f041fb4..2ac34b428 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -3190,6 +3190,42 @@ fn a_moved_target_defers_its_jump_alike_on_both_hosts() { /// place (its grid hold freezes the lane on the step's target until 4,512), on either host. #[test] fn a_table_step_inside_a_deferred_moves_ramp_lands_alike_on_both_hosts() { + table_step_inside_a_deferred_move(model_at); +} + +/// #1655 U3 fold 1 (MAJOR-1) across hosts: the composed twin of +/// [`a_table_step_inside_a_deferred_moves_ramp_lands_alike_on_both_hosts`]. `t1` is also in VCA +/// `v`, whose `offset_db` rides linearly from 0 to -6 dB over 48,000 samples in every plan, so +/// `t1`'s fader channels are composed lanes over its own ride and the VCA's. The adoption holds +/// and defers every cursor; the own table's step at 2,000 replaces the move's ramp on the whole +/// lane, as one cell's step does. Both hosts render the same bits as each other and as the fresh +/// pair with the same VCA ride, at the four rates, `EXACT` with no transition fallback. +/// +/// Test value: red if a composed lane's jump drops only the stepping cursor's deferral and hold +/// (the VCA cursor then freezes the lane until 4,512 while the fresh pair follows the ride), on +/// either host. +#[test] +fn a_table_step_inside_a_deferred_moves_ramp_releases_a_composed_lane_alike_on_both_hosts() { + table_step_inside_a_deferred_move(|rate| { + let mut model = model_at(rate); + model.vcas = vec![vca_at(VCA.id, 0.0, &[T1.id])]; + model.automation.push(vca_entry( + VCA.id, + VCA_OFFSET_DB, + vec![vca_piece( + session::AutomationShape::Linear, + (0, 48_000), + (0.0, -6.0), + session::ParameterUnit::Db, + )], + )); + model + }); +} + +/// The body of [`a_table_step_inside_a_deferred_moves_ramp_lands_alike_on_both_hosts`] over the +/// session `model_of(rate)`. +fn table_step_inside_a_deferred_move(model_of: fn(u32) -> SessionModel) { let segment = |shape, start, end, from, to| session::AutomationSegment { shape, start_sample: start, @@ -3236,7 +3272,7 @@ fn a_table_step_inside_a_deferred_moves_ramp_lands_alike_on_both_hosts() { Some(4_000), ); for rate in RATES { - let model = model_at(rate); + let model = model_of(rate); let (mut browser, mut c_abi) = (Rig::new(&model), CAbi::new(&model)); let (mut browser_r, mut c_abi_r) = (Rig::new(&model), CAbi::new(&model)); let both = |edits: &[SessionEdit], browser: &mut Rig, c_abi: &mut CAbi, label: &str| { From 51c4fc75b6155898bff2c1324016e643976e3afc Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 17:20:42 +0000 Subject: [PATCH 32/36] Fold a flagged rebuild's landing into the send move window (#1654, root ruling B19-R9) The B17-R15 one-line fold: a flagged transaction rebuild also folds its revision and its landing ramp into the window, so a second rebuild inside its at-once landing is flagged too. Gates on the C ABI and both hosts (R1 and R2 flagged, R3 after the landing EXACT), red with the fold reverted. Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 25 ++++++ crates/capi/src/runtime/live_tests.rs | 51 +++++++++++ crates/control-plane/src/control.rs | 5 +- hosts/host-web/tests/control_parity.rs | 90 +++++++++++++++++++ 4 files changed, 169 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 19c1a6dc7..190a179ae 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -991,3 +991,28 @@ On a route column, a value held by an earlier column jump follows the column's c never moves earlier (R19-3 (d), T3). It is deterministic and equal to a fresh plan. A single fader cell differs: there, a later step's jump replaces the ramp and shortens the hold, so the grid resumes after that step's own length. Unifying the two is phase 2 (TBD54, "Related"). + +## Decision record: B19-R9 (batch 19) + +- The fold (root ruling B19-R9, the B17-R15 pattern): a transaction rebuild that the B19-R8 term + flags (`send_fallback`) now also folds its own at-once landing into the send move window, as a + rebuild that loses a curve already did. In `crates/control-plane/src/control.rs` + `rebuild_send_ramp` is `follow_retarget_ramp.max(send_landing_ramp(next))` when + `follow_loses_a_curve || send_fallback`. The window then holds (the rebuild's revision, J), so + a second rebuild submitted inside the first one's landing is flagged too. Control plane only; no + render edit. The doc text keeps its meaning ("whenever any live send move may still ramp") and + is unchanged. +- Gates (`send_ride_document` / `follow_model` with the riding `t1-b` gain, quantum 128, J = 480): + a hand `gain_db` `+3` from 512 over 200 samples; R1 (direct route gain -3 dB) adopts at 640 + `TRANSITION_FALLBACK`, count 1; R2 (-6 dB) at 896, after the hand move ended but inside R1's + landing, `TRANSITION_FALLBACK`, count 2; R3 (-9 dB) at block 16 (2,048), no live move, `EXACT`, + count 2. + - C ABI: `a_second_rebuild_inside_a_flagged_rebuilds_landing_is_flagged_on_the_c_abi` + (`crates/capi/src/runtime/live_tests.rs`). + - Both hosts, same bits every block, one watermark: + `a_second_rebuild_inside_a_flagged_rebuilds_landing_is_flagged_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`). +- Red evidence: with the fold reverted (the guard back to `if follow_loses_a_curve`), both gates + fail at R2: the C ABI reads outcome 1 (`EXACT`) where 2 (`TRANSITION_FALLBACK`) is expected, + and the host parity gate reads "R2: the rebuild's outcome" 1 against 2. Restored, both pass. +- No other expectation changed. diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 9edbe67cd..a13970f8c 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -12528,3 +12528,54 @@ fn a_send_move_and_a_row_removal_in_one_transaction_are_flagged_on_the_c_abi() { ); } } + +/// #1654 root ruling B19-R9 (the B19-R8 check's repro) on the C ABI. On [`send_ride_document`] +/// (quantum 128, 480-sample landings), a hand gain move of `+3` from 512 over 200 samples is in +/// flight when R1 (the direct route `eq0-main` to -3 dB) rebuilds: R1 adopts at 640 flagged +/// (count 1), and render may land the riding send at once there over the landing length. R2 +/// (`eq0-main` to -6 dB), submitted at 896 after the hand move ended but inside R1's landing, +/// completes `TRANSITION_FALLBACK` too (count 2). R3 (`eq0-main` to -9 dB), long after every +/// landing ended, with no live send move, completes `EXACT` (count stays 2). +/// +/// Test value: red if a flagged rebuild does not fold its own at-once landing into the send move +/// window (a second rebuild inside it reads `EXACT`), or if the window outlives that landing. +#[test] +fn a_second_rebuild_inside_a_flagged_rebuilds_landing_is_flagged_on_the_c_abi() { + let mut rig = Rig::new(&send_ride_document()); + assert_eq!(rig.quantum, 128); + for _ in 0..4 { + rig.step(); + } + let (start, _) = live_and_render(&mut rig, &[send_row_edit("gain_db", 3.0, Some(200))], 1); + assert_eq!(start, 512, "the hand move starts at 512"); + let direct = |rig: &mut Rig, db: f32| { + let mut route = rig + .model() + .routes + .into_iter() + .find(|route| route.id.as_str() == "eq0-main") + .expect("the direct route"); + route.gain_db = db; + [SessionEdit::UpsertRoute { route }] + }; + let count = |rig: &Rig| { + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + watermark.transition_fallback_count + }; + // (label, submit block, gain, outcome, fallback count) + for (label, block, db, outcome, fallbacks) in [ + ("R1", 5, -3.0, crate::OUTCOME_TRANSITION_FALLBACK, 1), + ("R2", 7, -6.0, crate::OUTCOME_TRANSITION_FALLBACK, 2), + ("R3", 16, -9.0, crate::OUTCOME_EXACT, 2), + ] { + while rig.block < block { + rig.step(); + } + assert_eq!(rig.block, block, "{label}: submitted at its block"); + let edits = direct(&mut rig, db); + let (first, _) = rebuild_and_adopt_as(&mut rig, &edits, 1, Some(outcome)); + assert_eq!(first, block * 128, "{label}: adopted at its submit block"); + assert_eq!(count(&rig), fallbacks, "{label}: the fallback count"); + } +} diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 641cba2e1..7dc3c5b7b 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -2623,9 +2623,10 @@ where ) })); // The rebuild's own send moves join the window with the commit: its join's records, and - // a removed row's landing. + // a landing at once at the swap (a removed row's, or one a flagged rebuild may make, + // B19-R9), so a second rebuild inside that landing is flagged too. let rebuild_send_ramp = match &kind { - Rebuild::Transaction(prepared) if follow_loses_a_curve => { + Rebuild::Transaction(prepared) if follow_loses_a_curve || send_fallback => { let session = prepared.get().prospective_session(); let next = session .compiled() diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 9bbe21d17..de795d836 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -7308,3 +7308,93 @@ fn a_rebuild_carrying_a_following_send_across_a_live_send_move_is_flagged_alike_ ); } } + +/// #1654 root ruling B19-R9 (the B19-R8 check's repro) on both hosts. `t1-b` rides its `gain_db` +/// (0 to -12 dB; 480-sample landings at quantum 128). A hand gain move of `+3` from 512 over 200 +/// samples is in flight when R1 (the direct route `t1-main` to -3 dB, block 5) rebuilds: it adopts +/// at 640 flagged (count 1). R2 (`t1-main` to -6 dB, block 7, sample 896) comes after the hand +/// move ended but inside R1's at-once landing: `TRANSITION_FALLBACK` (count 2). R3 (`t1-main` to +/// -9 dB, block 16), after every landing ended with no live send move: `EXACT` (count stays 2). +/// Every block both hosts render the same bits. +/// +/// Test value: red if either host's flagged rebuild does not fold its own at-once landing into the +/// send move window (R2 reads `EXACT`), if the window outlives that landing (R3 flagged), or if +/// the hosts disagree. +#[cfg(feature = "test-support")] +#[test] +fn a_second_rebuild_inside_a_flagged_rebuilds_landing_is_flagged_alike_on_both_hosts() { + use session::{AutomationShape, ParameterUnit}; + const RUN: u64 = 24; + assert_eq!(QUANTUM, 128); + let end = u64::from(QUANTUM) * BLOCKS; + let mut model = follow_model(false, false); + model.automation.push(send_entry( + "t1-b-gain", + 1, + vec![send_piece( + AutomationShape::Linear, + (0, 0.0), + (end, -12.0), + ParameterUnit::Db, + )], + )); + let direct = |db: f32| { + let mut route = model + .routes + .iter() + .find(|route| route.id.as_str() == "t1-main") + .expect("the direct route") + .clone(); + route.gain_db = db; + vec![SessionEdit::UpsertRoute { route }] + }; + let hand = send_edit(1, 3.0, Some(200)); + // (label, rebuild block, edits, outcome, fallback count) + let rebuilds = [ + ("R1", 5, direct(-3.0), capi::OUTCOME_TRANSITION_FALLBACK, 1), + ("R2", 7, direct(-6.0), capi::OUTCOME_TRANSITION_FALLBACK, 2), + ("R3", 16, direct(-9.0), capi::OUTCOME_EXACT, 2), + ]; + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + for block in 0..RUN { + if block == 4 { + both_commit( + &mut browser, + &mut c_abi, + &hand, + EditPath::Live, + "the hand move", + ); + } + let rebuild = rebuilds.iter().find(|rebuild| rebuild.1 == block); + if let Some((label, _, edits, _, _)) = rebuild { + both_commit(&mut browser, &mut c_abi, edits, EditPath::Rebuild, label); + } + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "block {block}: the two hosts agree"); + if let Some((label, _, _, outcome, fallbacks)) = rebuild { + let watermark = c_abi.watermark(); + assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); + assert_eq!( + status_watermark(&browser), + watermark, + "{label}: one watermark" + ); + assert_eq!( + watermark.1, + block * u64::from(QUANTUM), + "{label}: adopted at its submit block" + ); + assert_eq!(watermark.2, *outcome, "{label}: the rebuild's outcome"); + assert_eq!( + ( + browser.host.watermark().transition_fallback_count, + c_abi.transition_fallback_count() + ), + (*fallbacks, *fallbacks), + "{label}: the fallback count" + ); + } + } +} From aa8c6eef6cc150b988deaae7f65bc59483e73c85 Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 18:15:09 +0000 Subject: [PATCH 33/36] Turn send-row automation off and keep the route-op machinery (#1654, root ruling B19-R10) Route rows 1-6 no longer render or take an offset, as on main 323433f6a: route-row entries are inert and no new refusal exists. Route cells, flag bits 6-11, the gain law and the value-cursor paths had no production caller left and are deleted. The route op on K1 stays for source-mute and VCA-mute following routes, with the B19-R7/R8/R9 conservative flag. The send-row gates are deleted; the machinery gates are adapted to mute curves; a new both-host gate shows route-row entries are inert. LIVE_FOLLOW_ROUTE_OWNER_BYTES 952 -> 824. Co-Authored-By: Claude Opus 5.5 --- ...ed-send-gain-mute-and-matrix-automation.md | 76 + ...ved-send-row-exactly-across-a-live-move.md | 36 +- crates/builtins-compiler/src/lib.rs | 247 +- crates/capi/src/runtime/live_tests.rs | 1013 ++------ crates/control-plane/src/control.rs | 26 +- .../tests/send_automation_realtime.rs | 355 --- crates/controls/src/automation.rs | 125 +- crates/controls/src/lib.rs | 7 +- crates/controls/src/mask.rs | 29 +- crates/controls/src/owner_edits.rs | 22 +- crates/controls/src/registry.rs | 24 +- crates/controls/src/route.rs | 94 +- crates/controls/src/view.rs | 21 - crates/controls/tests/automation.rs | 271 +-- crates/graph-compiler/src/ids.rs | 110 +- crates/graph-compiler/src/lib.rs | 2 +- crates/graph/src/lib.rs | 439 +--- crates/graph/src/runtime.rs | 2027 ++++------------- crates/host-core/src/live_delta.rs | 21 +- crates/host-core/src/prepare.rs | 285 +-- crates/host-core/tests/live_delta.rs | 297 +-- crates/host-core/tests/route_mute.rs | 445 +--- docs/SESSION_SCHEMA_V1.md | 39 +- hosts/host-web/tests/control_parity.rs | 1237 +++------- 24 files changed, 1322 insertions(+), 5926 deletions(-) delete mode 100644 crates/control-plane/tests/send_automation_realtime.rs diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 190a179ae..7fb1f3671 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -6,6 +6,19 @@ platform* (#1058), owner answer OQ2 = yes (2026-10-14, README "Owner answers"), Written by the controls coordinator on 2026-10-14. Code anchors verified on `12d4a4f9d` (batch 9 head). Batch R2, after #1651-#1653. +## Status (batch 19b, root ruling B19-R10) + +- **Send-row rendering is not shipped.** Route rows 1-6 do not render, the send `gain_db` takes no + hand offset, and a session with route-row entries validates, prepares, renders and lists its + controls exactly as on `main` `323433f6a` (the entries are inert; no new refusal). +- **The route-op machinery shipped with batch 19b**: the following route op on K1's merged walk + (T3), its carry, the U2 fold fixes and the conservative B19-R7/R8/R9 transition-fallback term. + Its production callers are source-mute and VCA-mute following routes (#1652, #1655). +- **This issue stays open in phase 2 behind TBD54.** It turns send rows on (route cells, flags + bits 6-11, the send-gain offset and the gain law, the send-row gates) after TBD54 lands the + cut exactly. The decision records below describe the batch 19 attempt; the B19-R10 record at + the end lists what was removed. + ## Product outcome A producer's stored send rides play on every host: a route into a submix follows its stored @@ -1016,3 +1029,66 @@ that step's own length. Unifying the two is phase 2 (TBD54, "Related"). fail at R2: the C ABI reads outcome 1 (`EXACT`) where 2 (`TRANSITION_FALLBACK`) is expected, and the host parity gate reads "R2: the rebuild's outcome" 1 against 2. Restored, both pass. - No other expectation changed. + +## Decision record: B19-R10 (batch 19b) + +Root ruling B19-R10 (option B): send-row automation is off, the route-op machinery stays. Nothing +under `crates/engine/src/realtime/`; no row class or table changed (the SDK is unchanged). + +- **Reverted to `main` `323433f6a`.** `controls`: `automation_row_renders` is fader, mute and VCA + rows again (no route rows 1-6); `takes_offset` is `fader_db` and VCA `offset_db`; the shared-row + offset branch of `offset_faults` is gone, so a send-row offset other than `+0.0` is + `control.automation.offset` as on main; `route.rs` is main's; the route-row paths of + `registry`, `view`, `owner_edits` and `mask` and the list flag are gone. Deleted: + `route_row`, `route_gain_row`, `route_automation`, `continued_route_rows`, + `ModelView::route_automation`. `graph-compiler`: `route_gain_law` deleted (`ids.rs` and `lib.rs` + are main's). +- **Deleted, no production caller left.** `builtins-compiler`: the route cells in the one program + and `route_cells` (VCA cells now follow the mute cells). `graph`: `RouteFollowFlags` bits 6-11 + (`DEFINED = (1 << 6) - 1`), `route_rows`, `automated_set`, `RouteAutomatedSet` (the producer's + `automated()` and `GraphLiveRoute::automated` are `[bool; 2]` again), `GRAPH_ROUTE_ROWS`, + `GraphRouteGainLaw`, `GraphFollowRouteSource::{route_cells, gain_law, continued, + with_continued_rows, with_gain_offset, gain_offset}`, and `GraphRouteCursorRow::{Gain, Mute, + Matrix}` with their jump lengths. Render (`runtime.rs`): the gain, route-mute and matrix roles, + the gain seed, the value-cursor carry paths (`place_new_values`, `for_placed_values`, + `Placement`, `ColumnRamps::remaining`), the `compose_toward` record branch and the route + column's `with_held_grid` (value cursors only). `host-core`: the route-cell attach, the flags + bits, the gain-offset seed, `route_gain_offset` and the record's offset word + (`follow_route_record*` are main's, with `follow_route_flags`). +- **Kept.** K1, the route op on K1 for source-mute and VCA-mute cursors (column walks, probe twin, + carry, R19-4 coincident gate events, the U2 fold fixes that gates reach), `follow_route_producer_for` + (the pre-write check, now over the two lane bits), the B19-R7/R8/R9 conservative term and its + doc text on the five surfaces: the term still fires without route rows (a live send move on a + mute-following send across any seven-to-seven rebuild). `send_landing_ramp` is now the mute + length (the only at-once landing length left). +- **Gates.** Deleted: the send-row gates of T1-T5, U2 folds and B19-R7 (controls, graph + `send_automation_tests`, host-core `live_delta`/`route_mute` send rows, control-plane + `send_automation_realtime.rs`, capi and control_parity send rows). Adapted to mute curves: + graph `follow_curve_tests::{coincident_follow_curve_events_take_the_longest_jump (R19-4), + a_live_send_move_in_flight_at_a_swap_keeps_its_length (R19-5), + a_removed_follow_curve_lands_the_column_on_its_static_value, + a_removed_follow_curve_lands_at_once_across_a_move_in_flight, + a_removed_follow_curve_leaves_no_stale_value_after_a_seek_or_a_further_rebuild, + a_hand_mute_and_a_gate_fade_together, + a_source_mute_added_while_a_move_is_in_flight_fades_its_column_at_once}` and the four VCA + gates; host-core `removing_a_source_mute_curve_of_a_following_send_lands_on_the_fresh_plan` + (three widths), `a_following_send_binds_its_lanes_and_the_commit_refuses_others`; capi + `removing_a_source_mute_curve_of_a_following_send_lands_on_the_fresh_plan_on_the_c_abi`, + `an_unrelated_rebuild_across_a_live_send_move_on_a_following_send_is_flagged_on_the_c_abi` + (also twin bits), `a_send_move_inside_a_rebuild_is_flagged_on_the_c_abi`, + `a_second_rebuild_inside_a_flagged_rebuilds_landing_is_flagged_on_the_c_abi`; control_parity + `a_rebuild_carrying_a_following_send_across_a_live_send_move_is_flagged_alike_on_both_hosts` + (also never-rebuilt bits), `a_second_rebuild_inside_a_flagged_rebuilds_landing_is_flagged_alike_on_both_hosts`. + Not expressible with mute curves (deleted): value holds and landings (R19-3 grid holds, value + jumps, removed-row lengths on later jumps). Kept as is: the output-route refusal (D8) on both + hosts. +- **New gate (B19-R10).** control_parity `stored_send_row_automation_is_inert_alike_on_both_hosts`: + a send with entries on rows 1-6, six-word and seven-word, prepares, renders every block (with a + live send gain move and an inert entry upsert, `model_only`) and lists its controls exactly as + the session without the entries, on both hosts. Green. +- **Docs.** `docs/SESSION_SCHEMA_V1.md`: send rows do not render and take no offset (main's text), + #1655's VCA text kept. +- **Sizes (x86-64, measured).** `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 952 -> 824, + `GraphFollowRouteSource` 120 -> 80, `RouteFollowFlags` 12 -> 6, `FollowRouteControlRecord` + 36 -> 32, `GraphLiveRoute` 32 -> 24, `GraphFollowRouteControlBinding` 808 -> 672, + `GraphFollowRouteControlProducer` 104. Realtime policy: 171 marked regions in 34 files. diff --git a/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md b/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md index 79ee1ee43..300f1e75b 100644 --- a/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md +++ b/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md @@ -34,6 +34,19 @@ keeps its own length, whatever else the rebuild changes. MAJOR-C) and broken by a seek (its time was a node time computed from a render-time remaining, and a seek dropped it, leaving a stale value: MAJOR-D). Probes: the batch 19 verifier's `scratchpad/u2f2v/probes*.rs` (session scratchpad; summarised here). +- Root ruling B19-R10 (batch 19b): send-row rendering (route rows 1-6, the send-gain offset) is + not shipped; #1654 stays open in phase 2 behind this issue and turns send rows on after it. The + route-op machinery (the merged walk, the carry, the conservative flag) shipped with batch 19b + for its production callers, source-mute and VCA-mute following routes, so the cut and the flag + above are reached by those routes today. +- The last check of the whole send flag term (B19-R9, batch 19) found two more MAJORs, both of + the B19-R7/R8/R9 window: + - MAJOR-W1: a live send move committed while a rebuild is pending (submitted, not yet adopted) + is cut at the candidate's adoption, and the pending candidate is never marked (it reports + `OUTCOME_EXACT`). + - MAJOR-W2: the window closes at the record ramp's end (`first_sample + ramp`) while the + column's R19-3 landing tail still runs, so a rebuild inside that tail can cut the landing and + report `OUTCOME_EXACT`. - The design this issue must hold: an owed landing kept in render terms (samples left on the column's ramp, counted down by `frames`), carried by every carry (merged with a new one by the longer length, R19-4), never dropped by a seek unless the seek's landing composes that column, and never leaving a @@ -48,17 +61,26 @@ keeps its own length, whatever else the rebuild changes. unrelated rebuild keeps the move bit-equal to the never-rebuilt twin until its end, then a fresh plan; `OUTCOME_EXACT`; both hosts. The same for MAJOR-2 (one transaction: a `gain_db` move over 4,000, an upsert of one matrix row and the removal of another). -- G2. The B19-R7/B19-R8 flag (`PlanStateInventory::carries_a_following_route_into_a_following_route`, - `SendMoveWindow`) and its doc text on the five surfaces are deleted; its gates - (`an_unrelated_rebuild_across_a_live_send_move_on_a_riding_send_is_flagged_on_the_c_abi`, - `a_send_move_and_a_row_removal_in_one_transaction_are_flagged_on_the_c_abi`, - `a_rebuild_carrying_a_following_send_across_a_live_send_move_is_flagged_alike_on_both_hosts`, and - the flagged expectations of the B19-R7 gates) are replaced by G1 and G1b. +- G1c. MAJOR-W1: a live send move committed while a rebuild is pending keeps its own length + across the candidate's adoption (bit-equal to the never-rebuilt twin until its end, then a + fresh plan), `OUTCOME_EXACT`, both hosts; with the flag still in place, the pending candidate + completes `TRANSITION_FALLBACK`. +- G1d. MAJOR-W2: a rebuild inside a column's R19-3 landing tail (after the record ramp ended) + keeps the landing's own length (twin, then fresh plan), `OUTCOME_EXACT`, both hosts; with the + flag still in place, it completes `TRANSITION_FALLBACK`. +- G2. The B19-R7/B19-R8/B19-R9 flag (`PlanStateInventory::carries_a_following_route_into_a_following_route`, + `SendMoveWindow`) and its doc text on the five surfaces are deleted; its gates (batch 19b names, + on mute-following sends: `an_unrelated_rebuild_across_a_live_send_move_on_a_following_send_is_flagged_on_the_c_abi`, + `a_send_move_inside_a_rebuild_is_flagged_on_the_c_abi`, + `a_second_rebuild_inside_a_flagged_rebuilds_landing_is_flagged_on_the_c_abi`, + `a_rebuild_carrying_a_following_send_across_a_live_send_move_is_flagged_alike_on_both_hosts` and + `a_second_rebuild_inside_a_flagged_rebuilds_landing_is_flagged_alike_on_both_hosts`) are replaced + by G1 to G1d, which then run on send rows as well (#1654). - G3. Allocation-free render; no new panic path. ## Dependencies -- #1654 (batch 19), #1713 (the seven-to-six flag, separate). +- #1654 (phase 2; its send rows turn on after this issue), #1713 (the seven-to-six flag, separate). ## Related diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 1c10fde5c..3a63b05d6 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -3269,10 +3269,6 @@ struct FaderAutomationPlan { program_bytes: u64, /// Each strip that has an automated fader or mute lane, by track ID. strips: BTreeMap, PreparedStripAutomation>, - /// Each route into a submix that automates a row 1-6 (#1654 D1), by route ID: the program's - /// cell index of each row, in [`controls::route_automation`]'s order, `None` for a row no - /// entry drives. - routes: BTreeMap, [Option; 6]>, /// Each VCA with an automated `offset_db` or `mute` lane (#1655 D1), by VCA ID: the program's /// cell index of each `[row][lane]` (row 0 `offset_db`, row 1 `mute`), `None` for a lane no /// entry drives. @@ -3415,12 +3411,9 @@ pub fn fader_automation_sizes() -> FaderAutomationSizes { /// 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. Then one cell per automated row 1-6 of each route into a submix -/// (#1654 D1), in `model.routes` order and row order, with the fader row's rule for `gain_db` and -/// the matrix and the mute row's rule for `mute`; they follow every mute cell, so a session with -/// no route automation builds the program it built before #1654. `None` when no row that renders -/// is automated, so a session without stored automation prepares exactly as before; a session -/// with mute (or route) automation only builds those cells only. +/// before #1651, cell for cell. The VCA cells (#1655 D1) follow every mute cell. `None` when no +/// row that renders is automated, so a session without stored automation prepares exactly as +/// before; a session with mute (or VCA) automation only builds those 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 @@ -3478,7 +3471,7 @@ fn prepare_fader_automation( }; let reach_of = |index: usize| reach.get(index).map_or(&[][..], Vec::as_slice); // The fader and mute event lanes that automated VCA lanes reach, `(strip, lane, VCAs)`: their - // VCA cells follow every route cell, so they are filled in once those exist. Transient. + // VCA cells follow every mute cell, so they are filled in once those exist. Transient. let mut composed_faders: Vec<(Box, usize, Vec)> = Vec::new(); let mut composed_mutes: Vec<(Box, usize, Vec)> = Vec::new(); // The reaching VCAs (indices) whose `entries` row drives lane `lane`. @@ -3635,45 +3628,7 @@ fn prepare_fader_automation( }) .mute = cells; } - // #1654 D1: the route cells, after every mute cell, in `model.routes` order and row order: - // rows 1 (`gain_db`) and 3-6 (the matrix) with the fader's rule (grid 64, `End`), row 2 - // (`mute`) with the mute rule (step). Only a route into a submix renders a row (an output - // route's entry is refused at `.target`, D8), so a session without route automation builds - // the program it built before, cell for cell. - let mut routes = BTreeMap::new(); - for route in &model.routes { - if !matches!( - route.destination, - session::RouteDestination::SubmixInput { .. } - ) { - continue; - } - let entries = controls::route_automation(model, &route.id); - if entries.iter().all(Option::is_none) { - continue; - } - let mut cells = [None; 6]; - for (row, 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. - if segments.is_empty() { - return Err(refuse("host.automation.route", entry)); - } - cells[row] = Some( - u32::try_from(tables.len()) - .map_err(|_| diag("host.automation.route", "$.automation"))?, - ); - let rule = if row == 1 { - controls::MUTE_CELL_RULE - } else { - controls::FADER_CELL_RULE - }; - tables.push((entry, segments, rule, "host.automation.route")); - } - routes.insert(Box::::from(route.id.as_str()), cells); - } - // #1655 D1: the VCA cells, after every route cell, in ascending VCA ID, then row (`offset_db` + // #1655 D1: the VCA cells, after every mute cell, in ascending VCA ID, then row (`offset_db` // with the fader's rule, `mute` with the mute rule), then lane: one cell per automated VCA // lane, shared by every member it reaches. A session without VCA automation builds the program // it built before, cell for cell. @@ -3800,7 +3755,6 @@ fn prepare_fader_automation( program: Arc::new(program), program_bytes, strips, - routes, vcas, })) } @@ -6877,21 +6831,6 @@ impl PreparedBuiltinsSession { .then(|| (Arc::clone(&plan.program), cells)) } - /// Route `route_id`'s stored automation (#1654 D1), for its seven-word lane: the plan's one - /// automation program (the `Arc` [`Self::strip_mute_cells`] hands out) and the program's cell - /// index of each route row 1-6 (`gain_db`, `mute`, `matrix_ll`, `matrix_lr`, `matrix_rl`, - /// `matrix_rr`), `None` for a row no entry drives. `None` when the route automates no row (or - /// the session has no stored automation that renders). Control side; it clones one `Arc`. - #[must_use] - pub fn route_cells( - &self, - route_id: &str, - ) -> Option<(Arc, [Option; 6])> { - let plan = self.fader_automation.as_ref()?; - let cells = *plan.routes.get(route_id)?; - Some((Arc::clone(&plan.program), cells)) - } - /// VCA `vca_id`'s stored automation (#1655 D1), for the members' records and the following /// routes of the host (V3, V4): the plan's one automation program (the `Arc` /// [`Self::strip_mute_cells`] hands out) and the program's cell index of each `[row][lane]` @@ -17343,182 +17282,6 @@ mod tests { assert!(program.cell(2).is_none(), "mute cells only"); } - /// #1654 D1 (T4): the route cells follow every mute cell in the plan's one program, in - /// `model.routes` order and row order, with the fader row's rule for `gain_db` and the matrix - /// and the mute row's rule for the route's own `mute`; [`PreparedBuiltinsSession::route_cells`] - /// names each row's cell in that same program. A session without route automation builds the - /// program it built before; one with route automation only builds route cells only. - /// - /// Test value: red if a route cell is placed before a mute cell (the strip cells move), if a - /// row takes another row's rule (a gain on a step table or a mute on the grid), if the export - /// names another program, row or cell, or if a route-only session builds no program. - #[test] - fn the_route_cells_follow_the_mute_cells_in_the_one_program() { - let plain = n_track_session(2); - 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 sid = |text: &str| session::StableId::parse(text).expect("id"); - let segment = |shape, start_value, end_value, unit| session::AutomationSegment { - shape, - start_sample: 0, - end_sample: 4_096, - start_value, - end_value, - unit, - }; - let entry = |name: &str, owner, section, control, segment| session::Automation { - id: sid(name), - target: session::ControlAddress { - owner, - section, - control, - lane: session::ParameterChannel::Both, - }, - segments: vec![segment], - offset: session::AutomationOffset::default(), - }; - let route_entry = |name: &str, control, segment| { - entry( - name, - session::Owner::Route(sid("t00-bus")), - session::Section::Own, - control, - segment, - ) - }; - let gain = route_entry( - "send-gain", - 1, - segment( - session::AutomationShape::Linear, - -6.0, - -2.0, - session::ParameterUnit::Db, - ), - ); - let send_mute = route_entry( - "send-mute", - 2, - segment( - session::AutomationShape::Step, - 0.0, - 1.0, - session::ParameterUnit::Linear, - ), - ); - let lr = route_entry( - "send-lr", - 4, - segment( - session::AutomationShape::Linear, - 0.0, - 0.5, - session::ParameterUnit::Linear, - ), - ); - let strip_mute = entry( - "mute-t00", - session::Owner::Strip(sid(&track_name(0))), - session::Section::Builtins, - 6, - segment( - session::AutomationShape::Step, - 1.0, - 0.0, - session::ParameterUnit::Linear, - ), - ); - let prepare = |automation: Vec| { - let mut model = plain.normalized_model().clone(); - let mut send = model.routes[0].clone(); - send.id = sid("t00-bus"); - send.destination = session::RouteDestination::SubmixInput { - submix_id: sid("bus"), - }; - let mut out = model.routes[0].clone(); - out.id = sid("bus-main"); - out.source = session::RouteSource::Submix { - submix_id: sid("bus"), - tap: session::SendTap::PostPan, - }; - model.routes.push(send); - model.routes.push(out); - let console = model.console.clone(); - model - .submixes - .push(session::Submix::unity(sid("bus"), &console)); - model.automation = automation; - let compiled = compile_session(&model, caps_all).expect("the session compiles"); - prepare_session_builtins(&compiled, &[], caps()).expect("prepared") - }; - let mute_segments = controls::stored_segments(&strip_mute); - let describe = |entry: &session::Automation, rule: controls::CellRule| { - automation::ProgramBuilder::bytes(&[rule.describe(&controls::stored_segments(entry))]) - }; - - let strip_only = prepare(vec![strip_mute.clone()]); - assert!(strip_only.route_cells("t00-bus").is_none()); - let strip_bytes = strip_only - .fader_automation - .as_ref() - .expect("cells") - .program_bytes; - - let both = prepare(vec![ - lr.clone(), - strip_mute.clone(), - gain.clone(), - send_mute.clone(), - ]); - let plan = both.fader_automation.as_ref().expect("cells"); - assert_eq!( - plan.program_bytes, - automation::ProgramBuilder::bytes(&[ - controls::MUTE_CELL_RULE.describe(&mute_segments), - controls::MUTE_CELL_RULE.describe(&mute_segments), - controls::FADER_CELL_RULE.describe(&controls::stored_segments(&gain)), - controls::MUTE_CELL_RULE.describe(&controls::stored_segments(&send_mute)), - controls::FADER_CELL_RULE.describe(&controls::stored_segments(&lr)), - ]), - "the strip's mute cells, then the route's rows in row order with their rules" - ); - let (program, cells) = both.route_cells("t00-bus").expect("the send's cells"); - assert!( - Arc::ptr_eq(&program, &plan.program), - "the plan's one program" - ); - assert_eq!(cells, [Some(2), Some(3), None, Some(4), None, None]); - assert_eq!( - both.strip_mute_cells(&track_name(0)) - .map(|(_, cells)| cells), - Some([Some(0), Some(1)]), - "the strip's cells keep their indices" - ); - assert!(both.route_cells("bus-main").is_none(), "an output route"); - assert_ne!(strip_bytes, plan.program_bytes); - - let route_only = prepare(vec![gain.clone()]); - let (program, cells) = route_only - .route_cells("t00-bus") - .expect("a route-only session builds its route cells"); - assert_eq!(cells, [Some(0), None, None, None, None, None]); - assert!(program.cell(1).is_none(), "route cells only"); - assert_eq!( - route_only - .fader_automation - .as_ref() - .expect("cells") - .program_bytes, - describe(&gain, controls::FADER_CELL_RULE) - ); - } - struct HarnessSink; impl GraphRuntimeProcessor for HarnessSink { diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index a13970f8c..58384083c 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -11541,192 +11541,6 @@ fn an_edit_of_a_following_route_is_live() { } } -/// #1654 T4: [`follow_document`]`(false, false)` (no source mute curve) with the send `eq0-b` -/// riding its `gain_db` linearly from 0 to -12 dB over the source: a route-row-only send, which -/// the plan gives the seven-word cell. -fn send_ride_document() -> String { - with_model(&follow_document(false, false), |model| { - model.automation.push(session::Automation { - id: StableId::parse("send-gain").expect("automation ID"), - target: session::ControlAddress { - owner: session::Owner::Route(StableId::parse("eq0-b").expect("route ID")), - section: session::Section::Own, - control: 1, - lane: session::ParameterChannel::Both, - }, - segments: vec![session::AutomationSegment { - shape: session::AutomationShape::Linear, - start_sample: 0, - end_sample: SOURCE_FRAMES, - start_value: 0.0, - end_value: -12.0, - unit: session::ParameterUnit::Db, - }], - offset: session::AutomationOffset::default(), - }); - }) -} - -/// The send `eq0-b`'s `gain_db` row (route row 1), set to `db`. -fn send_gain_edit(db: f32) -> SessionEdit { - use protocol::control_core::{Lane, Owner, Section, row_address}; - 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"), - db, - None, - ) -} - -/// #1654 R17-6 and root's ruling R17-1 on the C ABI. On a send whose `gain_db` stored entry -/// renders, `SET_CONTROL gain_db = +3` is `live`, one revision on the same plan, one write of the -/// send's seven-word cell; the committed document stores `+3` as the entry's offset on both -/// fields and keeps the static gain. The in-place leg commits in place; the same transaction by -/// the full path gives the same reply, document, cell writes and bits, and no render block of -/// either leg allocates or frees. -/// -/// Test value: red if the in-place path answers otherwise than the full path for the send gain -/// (an owners model with no route entry reads the row as not automated and writes the static -/// gain, or commits `model_only`), or if the move is not one seven-word write. -#[test] -fn a_hand_move_on_an_automated_send_gain_commits_alike_in_place_and_full() { - bench_support::alloc::assert_installed(); - struct Leg { - reply: Vec, - committed: String, - writes: Vec, - blocks: Vec>, - } - let before = send_ride_document(); - let leg = |label: &str| { - let mut rig = Rig::new(&before); - for _ in 0..4 { - rig.step(); - } - let (revision, _, epoch, pending) = rig.summary(); - let bytes = rig.transaction(&[send_gain_edit(3.0)]); - let (result, reply) = rig.send(&bytes); - assert_eq!(result, crate::RESULT_OK, "{label}"); - assert_eq!(edit_path_of(&reply), protocol::EditPath::Live, "{label}"); - assert_eq!( - (rig.summary().0, rig.summary().2, rig.summary().3), - (revision + 1, epoch, pending), - "{label}: one revision, the same plan, no candidate" - ); - let writes = rig.current_follow_route_cells(); - let committed = rig.snapshot(); - let blocks = (0..12) - .map(|_| { - let (pcm, used) = rig.step_counted(); - assert_eq!( - (used.allocations, used.deallocations), - (0, 0), - "{label}: a render block allocated or freed" - ); - bits(&pcm) - }) - .collect(); - Leg { - reply, - committed, - writes, - blocks, - } - }; - control_plane::test_take_in_place_counts(); - let in_place = leg("in place"); - assert_eq!( - control_plane::test_take_in_place_counts(), - (1, 0), - "the hand move commits in place" - ); - assert_eq!(in_place.writes, vec![1], "one seven-word cell write"); - let model = parse_session_json(&in_place.committed).expect("model"); - let entry = model - .automation - .iter() - .find(|entry| entry.id.as_str() == "send-gain") - .expect("the ride"); - assert_eq!( - ( - entry.offset.left_db.to_bits(), - entry.offset.right_db.to_bits() - ), - (3.0_f32.to_bits(), 3.0_f32.to_bits()), - "the entry stores the move on both fields" - ); - let send = model - .routes - .iter() - .find(|route| route.id.as_str() == "eq0-b") - .expect("the send"); - assert_eq!( - send.gain_db.to_bits(), - 0.0_f32.to_bits(), - "the static gain stays" - ); - - control_plane::test_force_full_preparation(true); - let full = leg("full path"); - control_plane::test_force_full_preparation(false); - assert_eq!(control_plane::test_take_in_place_counts().0, 0); - assert_eq!(full.reply, in_place.reply, "one reply on both paths"); - assert_eq!(full.committed, in_place.committed, "one document"); - assert_eq!(full.writes, in_place.writes, "one cell write"); - assert_eq!(full.blocks, in_place.blocks, "the same bits on both paths"); -} - -/// #1654 and the #1713 flag. A rebuild that removes the only route entry of a route-row-only send -/// (seven words -> six) completes as the transition fallback (`OUTCOME_TRANSITION_FALLBACK`, -/// count 1), with no code of its own: the join reads the plan's widened `following`. -/// -/// Test value: red if a route-row-only send is not a following route in the plan's live routes -/// (its seven -> six carry then completes exact while its ramp may be re-timed). -#[test] -fn removing_a_sends_only_route_entry_reports_a_fallback() { - let mut rig = Rig::new(&send_ride_document()); - for _ in 0..8 { - rig.step(); - } - assert_eq!( - rig.current_follow_route_cells(), - vec![0], - "one seven-word send" - ); - let bytes = rig.transaction(&[SessionEdit::RemoveAutomation { - automation_id: StableId::parse("send-gain").expect("automation ID"), - }]); - 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 successor is adopted"); - assert_eq!( - rig.current_follow_route_cells(), - Vec::::new(), - "the send is six-word again" - ); - 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_TRANSITION_FALLBACK, 1), - "the rebuild's revision, outcome flags and transition-fallback count" - ); -} - /// #1654 T5: an entry `name` on route `route`'s own row `row` (1 `gain_db`, 2 `mute`, 3-6 the /// matrix) with `segments`. fn route_entry( @@ -11748,24 +11562,7 @@ fn route_entry( } } -/// #1654 T5: one segment of `shape` from `(start, from)` to `(end, to)` in `unit`. -fn send_segment( - shape: session::AutomationShape, - (start, from): (u64, f32), - (end, to): (u64, f32), - unit: session::ParameterUnit, -) -> session::AutomationSegment { - session::AutomationSegment { - shape, - start_sample: start, - end_sample: end, - start_value: from, - end_value: to, - unit, - } -} - -/// #1654 T5: a flat curve at `value` over the source (step-shaped for a mute row). +/// A flat curve at `value` over the source (step-shaped for a mute row). fn send_flat( value: f32, unit: session::ParameterUnit, @@ -11776,16 +11573,18 @@ fn send_flat( } else { session::AutomationShape::Linear }; - vec![send_segment( + vec![session::AutomationSegment { shape, - (0, value), - (SOURCE_FRAMES, value), + start_sample: 0, + end_sample: SOURCE_FRAMES, + start_value: value, + end_value: value, unit, - )] + }] } -/// #1654 T5: the send `eq0-b`'s own row `name` (`gain_db`, `mute`, `matrix_lr`, ...) set to -/// `value`, over `ramp` samples when given. +/// The send `eq0-b`'s own row `name` (`gain_db`, `mute`, `matrix_lr`, ...) set to `value`, over +/// `ramp` samples when given. fn send_row_edit(name: &str, value: f32, ramp: Option) -> SessionEdit { use protocol::control_core::{Lane, Owner, Section, row_address}; SessionEdit::set_control( @@ -11801,272 +11600,52 @@ fn send_row_edit(name: &str, value: f32, ramp: Option) -> SessionEdit { ) } -/// #1654 T5: [`follow_document`]`(false, false)` with `entries` on its rows and `change` applied. -fn send_rows_document( - entries: Vec, - change: impl FnOnce(&mut SessionModel), -) -> String { +/// A mute entry `name` on `eq0`'s lane `lane`, flat at `value` (held open at 0, muted at 1). +fn lane_mute_entry(name: &str, lane: session::ParameterChannel, value: f32) -> session::Automation { + session::Automation { + id: StableId::parse(name).expect("automation ID"), + target: session::ControlAddress { + owner: session::Owner::Strip(StableId::parse("eq0").expect("track ID")), + section: session::Section::Builtins, + control: 6, + lane, + }, + segments: send_flat(value, session::ParameterUnit::Linear, true), + offset: session::AutomationOffset::default(), + } +} + +/// [`follow_document`]`(false, false)` whose send `eq0-b` follows a stored mute curve held open +/// (`eq0`'s mute entry flat at 0 on both lanes): a seven-word following send whose curves never +/// move, so a live send move on it is the only ramp in flight. +fn open_follow_document() -> String { with_model(&follow_document(false, false), |model| { - model.automation.extend(entries); - change(model); + model.automation.push(lane_mute_entry( + "eq0-mute", + session::ParameterChannel::Both, + 0.0, + )); }) } -/// #1654 gate 1 on the C ABI. The send `eq0-b` (`pre_fader`, `rr` only) with a flat `gain_db` -/// ride at -6 dB (hand offset `+0.0`, then `-0.0`), a flat `rr` entry at 0.5 and a flat route -/// `mute` at 0 renders, block after block, the bits of the session whose send holds those values -/// statically. Not vacuous: a ride at -5 dB differs. -/// -/// Test value: red if the C ABI's route op reads the gain word as the compiler's linear static -/// gain rather than the seeded offset, or converts or composes the curves with other arithmetic -/// than the static route. -#[test] -fn a_flat_send_ride_renders_the_static_sends_bits_on_the_c_abi() { - use session::ParameterUnit::{Db, Linear}; - let fixed = send_rows_document(Vec::new(), |model| { - let send = model - .routes - .iter_mut() - .find(|route| route.id.as_str() == "eq0-b") - .expect("the send"); - send.gain_db = -6.0; - send.channel_matrix.rr = 0.5; - }); - let automated = |gain_db: f32, offset: f32| { - let mut gain = route_entry("send-gain", "eq0-b", 1, send_flat(gain_db, Db, false)); - gain.offset = session::AutomationOffset { - left_db: offset, - right_db: offset, - }; - send_rows_document( - vec![ - gain, - route_entry("send-mute", "eq0-b", 2, send_flat(0.0, Linear, true)), - route_entry("send-rr", "eq0-b", 6, send_flat(0.5, Linear, false)), - ], - |_| {}, - ) - }; - for offset in [0.0_f32, -0.0] { - let mut curve = Rig::new(&automated(-6.0, offset)); - let mut fixed = Rig::new(&fixed); - assert_eq!( - curve.current_follow_route_cells(), - vec![0], - "one seven-word send" - ); - for block in 0..24 { - assert_eq!( - bits(&curve.step()), - bits(&fixed.step()), - "offset {offset:?}, block {block}" - ); - } - } - let mut other = Rig::new(&automated(-5.0, 0.0)); - let mut fixed = Rig::new(&fixed); - assert!( - (0..8).any(|_| bits(&other.step()) != bits(&fixed.step())), - "a ride at -5 dB is heard" - ); -} - -/// #1654 gate 3 on the C ABI, with gate 7 (the unrelated rebuild). On [`send_ride_document`] -/// (`eq0-b` rides its gain from 0 to -12 dB) with an `rr` ride from 1 to 0.25: -/// (a) an `UpsertAutomation` that changes the gain ride rebuilds, completes `EXACT`, and from the -/// end of the moved row's jump (`SendGain`, 240 samples) and the grid ramp after it, renders a -/// fresh plan of the new document, session-seeked to the adoption block; -/// (b) `SET_CONTROL gain_db = +3` is `live` and, after its ramp, renders a fresh plan whose entry -/// carries the offset `+3`; -/// (c) `SET_CONTROL matrix_rr` (automated) is `model_only` and moves no bit; -/// (d) `SET_CONTROL matrix_lr = 0.5` (not automated) is `live` and, after its ramp, renders a fresh -/// plan with that value; -/// (e) a rebuild that changes only the direct route `eq0-main` (into the output) completes `EXACT` -/// and the send's plane is bit for bit the never-swapped twin's. -/// -/// Test value: red if a send automation edit is not a rebuild or does not complete exactly, if a -/// hand send gain move writes the fallback instead of the offset (or the offset reaches the -/// matrix), if a hand edit of an automated matrix row fights the curve, if an un-automated value -/// of the group is lost, or if a running ride is re-timed across an unrelated rebuild. -#[test] -fn send_row_edits_rebuild_or_commit_as_the_rows_rule_says_on_the_c_abi() { - use session::{AutomationShape::Linear, ParameterUnit}; - let rr_ride = || { - route_entry( - "send-rr", - "eq0-b", - 6, - vec![send_segment( - Linear, - (0, 1.0), - (SOURCE_FRAMES, 0.25), - ParameterUnit::Linear, - )], - ) - }; - let document = with_model(&send_ride_document(), |model| { - model.automation.push(rr_ride()); - }); - let quantum = 128_usize; - // (a) A changed gain ride. - { - let mut rig = Rig::new(&document); - for _ in 0..4 { - rig.step(); - } - let mut changed = parse_session_json(&document).expect("model").automation[0].clone(); - assert_eq!(changed.id.as_str(), "send-gain"); - changed.segments[0].end_value = -24.0; - let (adoption, live) = rebuild_and_adopt( - &mut rig, - &[SessionEdit::UpsertAutomation { - automation: changed, - }], - ADOPTED_BLOCKS, - ); - let reference = seeked_reference(&rig.snapshot(), adoption, ADOPTED_BLOCKS); - let settled = 240 + 2 * 64; - let send = plane_bits(&live, 1, quantum); - let fresh = plane_bits(&reference, 1, quantum); - assert_ne!(send[..64], fresh[..64], "(a): the moved row jumps"); - assert_eq!( - send[settled..], - fresh[settled..], - "(a): a fresh plan after the jump" - ); - } - // (b) and (d): live hand moves, against a fresh plan of the committed document. - for (label, edit) in [ - ("(b) the gain offset", send_row_edit("gain_db", 3.0, None)), - ( - "(d) an un-automated matrix_lr", - send_row_edit("matrix_lr", 0.5, None), - ), - ] { - let mut rig = Rig::new(&document); - for _ in 0..4 { - rig.step(); - } - let (at, live) = live_and_render(&mut rig, std::slice::from_ref(&edit), 12); - let committed = rig.snapshot(); - if label.starts_with("(b)") { - let model = parse_session_json(&committed).expect("model"); - assert_eq!( - model.automation[0].offset.left_db.to_bits(), - 3.0_f32.to_bits() - ); - } - let reference = seeked_reference(&committed, at, 12); - // The move's 240 samples, the deferred landing's 240 and the grid ramp after it. - let from = 5; - assert_ne!(live[0], reference[0], "{label}: the move ramps"); - 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" - ); - } - } - // (c) An automated matrix row: automation wins. - { - let mut rig = Rig::new(&document); - let mut twin = Rig::new(&document); - for _ in 0..4 { - assert_eq!(bits(&rig.step()), bits(&twin.step())); - } - let bytes = rig.transaction(&[send_row_edit("matrix_rr", 0.75, None)]); - let (result, response) = rig.send(&bytes); - assert_eq!(result, crate::RESULT_OK); - assert_eq!( - edit_path_of(&response), - protocol::EditPath::ModelOnly, - "(c)" - ); - for block in 4..16 { - assert_eq!(bits(&rig.step()), bits(&twin.step()), "(c): block {block}"); - } - } - // (e) An unrelated rebuild: the send's plane is the never-swapped twin's. - { - let mut rig = Rig::new(&document); - let mut twin = Rig::new(&document); - for _ in 0..4 { - assert_eq!(bits(&rig.step()), bits(&twin.step())); - } - let mut direct = parse_session_json(&document) - .expect("model") - .routes - .into_iter() - .find(|route| route.id.as_str() == "eq0-main") - .expect("the direct route"); - direct.gain_db = -3.0; - let (adoption, live) = rebuild_and_adopt( - &mut rig, - &[SessionEdit::UpsertRoute { route: direct }], - ADOPTED_BLOCKS, - ); - while (twin.block as usize) * quantum < adoption as usize { - twin.step(); - } - let reference: Vec<_> = (0..live.len()).map(|_| twin.step()).collect(); - assert_eq!( - plane_bits(&live, 1, quantum), - plane_bits(&reference, 1, quantum), - "(e): the send's plane across the swap" - ); - assert_ne!( - plane_bits(&live, 0, quantum), - plane_bits(&reference, 0, quantum), - "(e): the rebuild is heard on the strip's plane" - ); - } +/// [`follow_document`]`(false, false)`'s direct route `eq0-main` at `db`: a rebuild that edits +/// only the direct route. +fn direct_route_edit(rig: &mut Rig, db: f32) -> SessionEdit { + let mut route = rig + .model() + .routes + .into_iter() + .find(|route| route.id.as_str() == "eq0-main") + .expect("the direct route"); + route.gain_db = db; + SessionEdit::UpsertRoute { route } } -/// #1654 gate 4 on the C ABI (D5: a hand send mute ORs with the automated send mute). `eq0-b`'s -/// own mute curve is open until 2,048 and closed after. At block 4 a hand mute is `live` and, -/// after the mute ramp, shuts the send while the curve is open; at block 20 (2,560: the curve -/// closed) a hand unmute is `live` and the send stays shut. -/// -/// Test value: red if the composition is not an OR (the hand mute does not close an open curve, -/// or the hand unmute reopens a closed one). -#[test] -fn a_hand_send_mute_ors_with_its_curve_on_the_c_abi() { - use session::{AutomationShape::Step, ParameterUnit::Linear}; - let document = send_rows_document( - vec![route_entry( - "send-mute", - "eq0-b", - 2, - vec![ - send_segment(Step, (0, 0.0), (2_048, 1.0), Linear), - send_segment(Step, (2_048, 1.0), (SOURCE_FRAMES, 1.0), Linear), - ], - )], - |_| {}, - ); - let quantum = 128_usize; - let mut rig = Rig::new(&document); - for _ in 0..4 { - rig.step(); - } - let (_, muted) = live_and_render(&mut rig, &[send_row_edit("mute", 1.0, None)], 16); - let send = plane_bits(&muted, 1, quantum); - assert!( - send[..64].iter().any(|&word| word != 0), - "the send plays at the edit" - ); - assert!( - send[480..].iter().all(|&word| f32::from_bits(word) == 0.0), - "the hand mute shuts the send while the curve is open" - ); - let (_, unmuted) = live_and_render(&mut rig, &[send_row_edit("mute", 0.0, None)], 12); - assert!( - plane_bits(&unmuted, 1, quantum) - .iter() - .all(|&word| f32::from_bits(word) == 0.0), - "the hand unmute leaves the closed curve closed" - ); +/// The transition-fallback count of `rig`'s watermark. +fn fallback_count(rig: &Rig) -> u64 { + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + watermark.transition_fallback_count } /// #1654 gate 5 on the C ABI: an automation entry on the route into the output `eq0-main` is @@ -12096,473 +11675,189 @@ fn an_output_route_entry_is_refused_on_the_c_abi() { assert_eq!(rig.summary(), before, "no revision"); } -/// #1654 U2 fold 1, MAJOR-3 on the C ABI. The send `eq0-b` keeps a flat `gain_db` ride at -6 dB -/// (its table continues) and a rebuild removes either its flat `rr` entry at 0.5 or `eq0`'s mute -/// entry (held muted): the send stays a following route, the join writes no record, and no cursor -/// hears the removed curve. The rebuild completes `EXACT`; from the first block boundary after the -/// landing over the removed row's own length (`SendMatrix`, 240, for `rr`; the mute length, 480, -/// for the mute curve) the send's plane is a fresh plan of the committed document, session-seeked -/// to the adoption block. +/// #1654 U2 fold 1, MAJOR-3 on the C ABI, on a mute-following send. The send `eq0-b` hears +/// `eq0`'s right lane, whose mute entry holds it muted; `eq0`'s left lane has its own (open) mute +/// entry. A rebuild removes the right lane's entry: the send stays a following route (the left +/// lane's curve keeps it seven-word), the join writes no record, and no cursor hears the removed +/// curve. The rebuild completes `EXACT`; from the first block boundary after the landing over the +/// removed curve's own length (the mute length, 480) the send's plane is a fresh plan of the +/// committed document, session-seeked to the adoption block. /// -/// Test value: red if a removed row or source mute curve of a send that stays seven-word leaves -/// the send on the removed curve's last value. +/// Test value: red if a removed source mute curve of a send that stays seven-word leaves the send +/// on the removed curve's last value (muted). #[test] -fn removing_a_send_row_or_its_source_mute_curve_lands_on_the_fresh_plan_on_the_c_abi() { - use session::ParameterUnit::{Db, Linear}; - let gain = || route_entry("send-gain", "eq0-b", 1, send_flat(-6.0, Db, false)); - let with_rr = send_rows_document( - vec![ - gain(), - route_entry("send-rr", "eq0-b", 6, send_flat(0.5, Linear, false)), - ], - |_| {}, - ); - let with_mute = with_model(&follow_document(true, false), |model| { - model.automation[0].segments = send_flat(1.0, Linear, true); - model.automation.push(gain()); +fn removing_a_source_mute_curve_of_a_following_send_lands_on_the_fresh_plan_on_the_c_abi() { + use session::ParameterChannel::{Left, Right}; + let document = with_model(&follow_document(false, false), |model| { + model + .automation + .push(lane_mute_entry("eq0-mute-left", Left, 0.0)); + model + .automation + .push(lane_mute_entry("eq0-mute-right", Right, 1.0)); }); - for (label, document, removed, length) in [ - ("rr", &with_rr, "send-rr", 240_usize), - ("the source mute", &with_mute, "eq0-mute", 480), - ] { - let mut rig = Rig::new(document); - for _ in 0..4 { - rig.step(); - } - let (adoption, live) = rebuild_and_adopt( - &mut rig, - &[SessionEdit::RemoveAutomation { - automation_id: StableId::parse(removed).expect("automation ID"), - }], - ADOPTED_BLOCKS, - ); - assert_eq!( - rig.current_follow_route_cells(), - vec![0], - "{label}: still one seven-word send, and the join writes no record" - ); - let quantum = rig.quantum; - let fresh = seeked_reference(&rig.snapshot(), adoption, ADOPTED_BLOCKS); - let (send, fresh) = ( - plane_bits(&live, 1, quantum), - plane_bits(&fresh, 1, quantum), - ); - assert_ne!( - send[..64], - fresh[..64], - "{label}: the send lands, from the old value" - ); - let landed = length.div_ceil(quantum) * quantum; - assert_eq!( - send[landed..], - fresh[landed..], - "{label}: a fresh plan after the landing" - ); + let mut rig = Rig::new(&document); + for _ in 0..4 { + rig.step(); } -} - -/// #1654 root ruling B19-R7 on the C ABI. The send `eq0-b` hears flat `ll` and `rr` entries at -/// 0.5; a hand gain move to -6 dB over 1,000 samples is in flight when a rebuild removes the -/// `ll` entry, so the send's left source column loses its last curve and lands at once over -/// `SendMatrix` (240), cutting the move there. The rebuild completes `TRANSITION_FALLBACK` -/// (count 1). Then: nothing (MAJOR-A); a second rebuild that edits only the direct route -/// (MAJOR-C: also `TRANSITION_FALLBACK` under B19-R8, the move still ramps; count 2); or a -/// session seek inside the move, forward or back (MAJOR-D). From the first block boundary after -/// the move's end and the landing, both planes are a fresh plan of the committed document, session-seeked to the last adoption or seek. The -/// same removal with the move ended long before completes `EXACT` and lands the same way. -/// -/// Test value: red if the rebuild completes `EXACT` while the cut move ramps (or is flagged with -/// no move), or if a removed row's value survives the landing, a seek or a further rebuild. -#[test] -fn removing_a_send_row_across_a_live_send_move_is_flagged_and_lands_on_the_fresh_plan_on_the_c_abi() -{ - use session::ParameterUnit::Linear; - const RAMP: u64 = 1_000; - const BLOCKS: usize = 48; - let document = send_rows_document( - vec![ - route_entry("send-ll", "eq0-b", 3, send_flat(0.5, Linear, false)), - route_entry("send-rr", "eq0-b", 6, send_flat(0.5, Linear, false)), - ], - |_| {}, + let (adoption, live) = rebuild_and_adopt( + &mut rig, + &[SessionEdit::RemoveAutomation { + automation_id: StableId::parse("eq0-mute-right").expect("automation ID"), + }], + ADOPTED_BLOCKS, + ); + assert_eq!( + rig.current_follow_route_cells(), + vec![0], + "still one seven-word send, and the join writes no record" + ); + let quantum = rig.quantum; + let fresh = seeked_reference(&rig.snapshot(), adoption, ADOPTED_BLOCKS); + let (send, fresh) = ( + plane_bits(&live, 1, quantum), + plane_bits(&fresh, 1, quantum), + ); + assert_ne!(send[..64], fresh[..64], "the send lands, from silence"); + let landed = 480_usize.div_ceil(quantum) * quantum; + assert_eq!( + send[landed..], + fresh[landed..], + "a fresh plan after the landing" ); - let quantum = 128_usize; - let fallback = crate::OUTCOME_TRANSITION_FALLBACK; - let remove = [SessionEdit::RemoveAutomation { - automation_id: StableId::parse("send-ll").expect("automation ID"), - }]; - let move_edit = send_row_edit("gain_db", -6.0, Some(RAMP as u32)); - // (label, the move's lead before the rebuild in blocks, then) - #[derive(Clone, Copy, Debug)] - enum Then { - Nothing, - Rebuild, - Seek(i64), - } - for (label, lead, then) in [ - ("MAJOR-A", 2, Then::Nothing), - ("MAJOR-C", 2, Then::Rebuild), - ("MAJOR-D inside", 2, Then::Seek(0)), - ("MAJOR-D forward", 2, Then::Seek(5_000)), - ("MAJOR-D back", 2, Then::Seek(-1)), - ("the move ended", 24, Then::Nothing), - ] { - let mut rig = Rig::new(&document); - for _ in 0..4 { - rig.step(); - } - let (start, _) = live_and_render(&mut rig, std::slice::from_ref(&move_edit), lead); - let ended = lead * quantum > RAMP as usize + 2 * quantum; - // A case that continues renders only the adoption block here. - let blocks = if matches!(then, Then::Nothing) { - BLOCKS - } else { - 1 - }; - let (adoption, mut live) = rebuild_and_adopt_as( - &mut rig, - &remove, - blocks, - Some(if ended { - crate::OUTCOME_EXACT - } else { - fallback - }), - ); - let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); - assert_eq!(result, crate::RESULT_OK); - assert_eq!( - watermark.transition_fallback_count, - u64::from(!ended), - "{label}: the fallback count" - ); - assert_eq!( - rig.current_follow_route_cells().len(), - 1, - "{label}: seven-word" - ); - let end = (start + RAMP).saturating_sub(adoption) as usize; - assert_eq!( - end > 0, - !ended, - "{label}: the move is in flight at the adoption" - ); - // The sample the reference starts at (render and timeline), and the blocks from it. - let (from, timeline) = match then { - Then::Nothing => (adoption, adoption), - Then::Rebuild => { - let mut direct = rig - .model() - .routes - .into_iter() - .find(|route| route.id.as_str() == "eq0-main") - .expect("the direct route"); - direct.gain_db = -3.0; - // B19-R8: the move still ramps, so the unrelated rebuild is flagged too. - let (second, after) = rebuild_and_adopt_as( - &mut rig, - &[SessionEdit::UpsertRoute { route: direct }], - BLOCKS, - Some(fallback), - ); - assert!(second < start + RAMP, "{label}: the move still runs"); - let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); - assert_eq!(result, crate::RESULT_OK); - assert_eq!( - watermark.transition_fallback_count, 2, - "{label}: both rebuilds are flagged" - ); - live = after; - (second, second) - } - Then::Seek(target) => { - let at = rig.block * quantum as u64; - assert!(at < start + RAMP, "{label}: the seek is inside the move"); - let timeline = match target { - 0 => at, - -1 => 256, - forward => forward as u64, - }; - rig.generation += 1; - assert_eq!( - crate::ffi::test_session_seek(rig.session, rig.generation, timeline, None), - crate::RESULT_OK - ); - rig.fed = timeline; - live = (0..BLOCKS).map(|_| rig.step()).collect(); - (at, timeline) - } - }; - let fresh = seeked_reference(&rig.snapshot(), timeline, BLOCKS); - let landed = ((start + RAMP).saturating_sub(from) as usize) - .max(adoption.saturating_sub(from) as usize + 240) - .div_ceil(quantum) - * quantum; - for plane in 0..2 { - let (send, fresh) = ( - plane_bits(&live, plane, quantum), - plane_bits(&fresh, plane, quantum), - ); - assert_eq!( - send[landed..], - fresh[landed..], - "{label}, plane {plane}: a fresh plan after the landing" - ); - } - } -} - -/// #1654 R19-5 on the C ABI. On [`send_ride_document`], a live send move with its own 4,000 -/// samples (the hand gain offset `+3`, or a D6 `matrix_lr` edit) is in flight when a rebuild -/// changes the gain ride's table (the carried cursor is repositioned). The rebuild completes -/// `TRANSITION_FALLBACK` (count 1): B19-R8's conservative term flags every rebuild that carries -/// a following send while a live send move may ramp, though this move is not cut. Until the -/// move's end every sample is a twin's that took the same move and no rebuild (the move keeps -/// its length); after the move, the moved row's jump and the grid ramp after it, the plan renders -/// a fresh plan of the committed document. -/// -/// Test value: red if a reposition at the swap cuts the live send move (lands the moved table -/// at once) or re-times it. -#[test] -fn a_live_send_move_in_flight_at_a_reposition_keeps_its_length_on_the_c_abi() { - const RAMP: u32 = 4_000; - let document = send_ride_document(); - let quantum = 128_usize; - for (label, edit) in [ - ("the gain offset", send_row_edit("gain_db", 3.0, Some(RAMP))), - ("matrix_lr", send_row_edit("matrix_lr", 0.5, Some(RAMP))), - ] { - let mut rig = Rig::new(&document); - let mut twin = Rig::new(&document); - for _ in 0..4 { - assert_eq!(bits(&rig.step()), bits(&twin.step())); - } - let (start, _) = live_and_render(&mut rig, std::slice::from_ref(&edit), 2); - let _ = live_and_render(&mut twin, std::slice::from_ref(&edit), 2); - // The re-authored ride keeps the entry's committed offset (R17-1), so the join writes - // no record and the move is the only ramp in flight. - let mut changed = rig.model().automation[0].clone(); - assert_eq!(changed.id.as_str(), "send-gain"); - changed.segments[0].end_value = -24.0; - let rebuild = SessionEdit::UpsertAutomation { - automation: changed, - }; - let writes = rig.current_follow_route_cells(); - let (adoption, live) = rebuild_and_adopt_as( - &mut rig, - std::slice::from_ref(&rebuild), - 48, - Some(crate::OUTCOME_TRANSITION_FALLBACK), - ); - assert_eq!( - rig.current_follow_route_cells(), - vec![0], - "{label}: the join writes no record ({writes:?} before)" - ); - let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); - assert_eq!(result, crate::RESULT_OK); - assert_eq!( - watermark.transition_fallback_count, 1, - "{label}: the conservative transition fallback (B19-R8)" - ); - while (twin.block as usize) * quantum < adoption as usize { - twin.step(); - } - let reference: Vec<_> = (0..live.len()).map(|_| twin.step()).collect(); - let end = (start + u64::from(RAMP) - adoption) as usize; - for plane in 0..2 { - let (live, twin) = ( - plane_bits(&live, plane, quantum), - plane_bits(&reference, plane, quantum), - ); - assert_eq!( - live[..end], - twin[..end], - "{label}, plane {plane}: the move runs" - ); - } - let fresh = seeked_reference(&rig.snapshot(), adoption, 48); - let settled = end + 240 + 2 * 64; - let (send, fresh) = ( - plane_bits(&live, 1, quantum), - plane_bits(&fresh, 1, quantum), - ); - assert_ne!( - send[end..settled], - fresh[end..settled], - "{label}: the moved row lands" - ); - assert_eq!( - send[settled..], - fresh[settled..], - "{label}: a fresh plan after the landing" - ); - } } -/// #1654 root ruling B19-R8 (the split verifier's MAJOR-1) on the C ABI. On -/// [`send_ride_document`] (`eq0-b` rides its gain), a live send move over 4,000 samples (the hand -/// gain offset `+3`, or a D6 `matrix_lr` edit) is in flight when a rebuild edits only the direct -/// route `eq0-main`: render may land the riding send's column at once at the swap, so the rebuild -/// completes `TRANSITION_FALLBACK` (count 1). With the move long finished the same rebuild -/// completes `EXACT` (with no hand move at all: gate (e) of -/// `send_row_edits_rebuild_or_commit_as_the_rows_rule_says_on_the_c_abi`). +/// #1654 root ruling B19-R8 (the split verifier's MAJOR-1) and R19-5 on the C ABI. On +/// [`open_follow_document`] (`eq0-b` follows a held-open mute curve), a live send move over +/// 4,000 samples (a D6 `gain_db` edit to -3 dB, or a `matrix_rr` edit to 0.5) is in flight when +/// a rebuild edits only the direct route `eq0-main`. The move keeps its own length: the send's +/// plane is the never-rebuilt twin's, bit for bit, through the move and after it. Control cannot +/// tell that, so the rebuild completes `TRANSITION_FALLBACK` (count 1, the conservative term). +/// With the move long finished the same rebuild completes `EXACT` (count 0). /// /// Test value: red if an unrelated rebuild that carries a following send completes `EXACT` while -/// a live send move may still ramp (B19-R7's term flagged only a rebuild that loses a curve), or -/// if it is flagged with no live send move in the window. +/// a live send move may still ramp, if it is flagged with no live send move in the window, or if +/// the carry cuts the move (R19-5). #[test] -fn an_unrelated_rebuild_across_a_live_send_move_on_a_riding_send_is_flagged_on_the_c_abi() { +fn an_unrelated_rebuild_across_a_live_send_move_on_a_following_send_is_flagged_on_the_c_abi() { const RAMP: u32 = 4_000; - let document = send_ride_document(); + let document = open_follow_document(); + let quantum = 128_usize; // (label, the live send move, its lead before the rebuild in blocks, flagged) for (label, edit, lead, flagged) in [ ( - "the gain offset in flight", - send_row_edit("gain_db", 3.0, Some(RAMP)), + "gain_db in flight", + send_row_edit("gain_db", -3.0, Some(RAMP)), 2, true, ), ( - "matrix_lr in flight", - send_row_edit("matrix_lr", 0.5, Some(RAMP)), + "matrix_rr in flight", + send_row_edit("matrix_rr", 0.5, Some(RAMP)), 2, true, ), ( "the move long finished", - send_row_edit("gain_db", 3.0, Some(RAMP)), + send_row_edit("gain_db", -3.0, Some(RAMP)), 40, false, ), ] { let mut rig = Rig::new(&document); + let mut twin = Rig::new(&document); for _ in 0..4 { - rig.step(); + assert_eq!(bits(&rig.step()), bits(&twin.step()), "{label}"); } let (start, _) = live_and_render(&mut rig, std::slice::from_ref(&edit), lead); + let _ = live_and_render(&mut twin, std::slice::from_ref(&edit), lead); let now = rig.block * rig.quantum as u64; assert_eq!( now < start + u64::from(RAMP), flagged, "{label}: the move is in flight at the rebuild" ); - let mut direct = rig - .model() - .routes - .into_iter() - .find(|route| route.id.as_str() == "eq0-main") - .expect("the direct route"); - direct.gain_db = -3.0; + let direct = direct_route_edit(&mut rig, -3.0); let outcome = if flagged { crate::OUTCOME_TRANSITION_FALLBACK } else { crate::OUTCOME_EXACT }; - let _ = rebuild_and_adopt_as( - &mut rig, - &[SessionEdit::UpsertRoute { route: direct }], - 1, - Some(outcome), - ); - let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); - assert_eq!(result, crate::RESULT_OK); + let (adoption, live) = rebuild_and_adopt_as(&mut rig, &[direct], 48, Some(outcome)); assert_eq!( - watermark.transition_fallback_count, + fallback_count(&rig), u64::from(flagged), "{label}: the fallback count" ); + while (twin.block as usize) * quantum < adoption as usize { + twin.step(); + } + let reference: Vec<_> = (0..live.len()).map(|_| twin.step()).collect(); + assert_eq!( + plane_bits(&live, 1, quantum), + plane_bits(&reference, 1, quantum), + "{label}: the send keeps its move, the twin's bits" + ); } } -/// #1654 root ruling B19-R8 (the split verifier's MAJOR-2) on the C ABI. The send `eq0-b` hears -/// flat `ll` (0.5), `rl` (0.1) and `rr` (0.5) entries. One transaction moves `gain_db` to -6 dB -/// over 4,000 samples, changes the `rl` entry and removes the `ll` entry: it rebuilds, and its -/// own send move (a join record) is cut where the removed row lands at once. The rebuild -/// completes `TRANSITION_FALLBACK` (count 1), though no earlier send move is in the window. The -/// same transaction without the move completes `EXACT`. +/// #1654 root ruling B19-R8 (the split verifier's MAJOR-2) on the C ABI. On +/// [`open_follow_document`], one transaction moves the send's `gain_db` to -3 dB over 4,000 +/// samples and edits the direct route `eq0-main`: it rebuilds, and its own send move is a join +/// record with a ramp. The rebuild completes `TRANSITION_FALLBACK` (count 1), though no earlier +/// send move is in the window. The direct-route edit alone completes `EXACT`. /// /// Test value: red if the flag reads only the send moves of earlier commits (the window folds the /// transaction's own records after the commit), or if a rebuild with no live send move is flagged. #[test] -fn a_send_move_and_a_row_removal_in_one_transaction_are_flagged_on_the_c_abi() { - use session::ParameterUnit::Linear; - let document = send_rows_document( - vec![ - route_entry("send-ll", "eq0-b", 3, send_flat(0.5, Linear, false)), - route_entry("send-rl", "eq0-b", 5, send_flat(0.1, Linear, false)), - route_entry("send-rr", "eq0-b", 6, send_flat(0.5, Linear, false)), - ], - |_| {}, - ); - let rows = [ - SessionEdit::UpsertAutomation { - automation: route_entry("send-rl", "eq0-b", 5, send_flat(0.3, Linear, false)), - }, - SessionEdit::RemoveAutomation { - automation_id: StableId::parse("send-ll").expect("automation ID"), - }, - ]; - for (label, moved) in [("the move with the rows", true), ("the rows alone", false)] { +fn a_send_move_inside_a_rebuild_is_flagged_on_the_c_abi() { + let document = open_follow_document(); + for (label, moved) in [ + ("the move with the rebuild", true), + ("the rebuild alone", false), + ] { let mut rig = Rig::new(&document); for _ in 0..4 { rig.step(); } let mut edits = Vec::new(); if moved { - edits.push(send_row_edit("gain_db", -6.0, Some(4_000))); + edits.push(send_row_edit("gain_db", -3.0, Some(4_000))); } - edits.extend(rows.iter().cloned()); + edits.push(direct_route_edit(&mut rig, -3.0)); let outcome = if moved { crate::OUTCOME_TRANSITION_FALLBACK } else { crate::OUTCOME_EXACT }; let _ = rebuild_and_adopt_as(&mut rig, &edits, 1, Some(outcome)); - let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); - assert_eq!(result, crate::RESULT_OK); assert_eq!( - watermark.transition_fallback_count, + fallback_count(&rig), u64::from(moved), "{label}: the fallback count" ); } } -/// #1654 root ruling B19-R9 (the B19-R8 check's repro) on the C ABI. On [`send_ride_document`] -/// (quantum 128, 480-sample landings), a hand gain move of `+3` from 512 over 200 samples is in -/// flight when R1 (the direct route `eq0-main` to -3 dB) rebuilds: R1 adopts at 640 flagged -/// (count 1), and render may land the riding send at once there over the landing length. R2 -/// (`eq0-main` to -6 dB), submitted at 896 after the hand move ended but inside R1's landing, -/// completes `TRANSITION_FALLBACK` too (count 2). R3 (`eq0-main` to -9 dB), long after every -/// landing ended, with no live send move, completes `EXACT` (count stays 2). +/// #1654 root ruling B19-R9 (the B19-R8 check's repro) on the C ABI. On [`open_follow_document`] +/// (quantum 128, 480-sample landings), a live send gain move to -3 dB from 512 over 200 samples +/// is in flight when R1 (the direct route `eq0-main` to -3 dB) rebuilds: R1 adopts at 640 flagged +/// (count 1), and render may land the send at once there over the landing length. R2 (`eq0-main` +/// to -6 dB), submitted at 896 after the move ended but inside R1's landing, completes +/// `TRANSITION_FALLBACK` too (count 2). R3 (`eq0-main` to -9 dB), long after every landing ended, +/// with no live send move, completes `EXACT` (count stays 2). /// /// Test value: red if a flagged rebuild does not fold its own at-once landing into the send move /// window (a second rebuild inside it reads `EXACT`), or if the window outlives that landing. #[test] fn a_second_rebuild_inside_a_flagged_rebuilds_landing_is_flagged_on_the_c_abi() { - let mut rig = Rig::new(&send_ride_document()); + let mut rig = Rig::new(&open_follow_document()); assert_eq!(rig.quantum, 128); for _ in 0..4 { rig.step(); } - let (start, _) = live_and_render(&mut rig, &[send_row_edit("gain_db", 3.0, Some(200))], 1); - assert_eq!(start, 512, "the hand move starts at 512"); - let direct = |rig: &mut Rig, db: f32| { - let mut route = rig - .model() - .routes - .into_iter() - .find(|route| route.id.as_str() == "eq0-main") - .expect("the direct route"); - route.gain_db = db; - [SessionEdit::UpsertRoute { route }] - }; - let count = |rig: &Rig| { - let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); - assert_eq!(result, crate::RESULT_OK); - watermark.transition_fallback_count - }; + let (start, _) = live_and_render(&mut rig, &[send_row_edit("gain_db", -3.0, Some(200))], 1); + assert_eq!(start, 512, "the move starts at 512"); // (label, submit block, gain, outcome, fallback count) for (label, block, db, outcome, fallbacks) in [ ("R1", 5, -3.0, crate::OUTCOME_TRANSITION_FALLBACK, 1), @@ -12573,9 +11868,13 @@ fn a_second_rebuild_inside_a_flagged_rebuilds_landing_is_flagged_on_the_c_abi() rig.step(); } assert_eq!(rig.block, block, "{label}: submitted at its block"); - let edits = direct(&mut rig, db); + let edits = [direct_route_edit(&mut rig, db)]; let (first, _) = rebuild_and_adopt_as(&mut rig, &edits, 1, Some(outcome)); assert_eq!(first, block * 128, "{label}: adopted at its submit block"); - assert_eq!(count(&rig), fallbacks, "{label}: the fallback count"); + assert_eq!( + fallback_count(&rig), + fallbacks, + "{label}: the fallback count" + ); } } diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 7dc3c5b7b..2d83ae4a2 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -652,7 +652,7 @@ pub struct SessionState { } /// Every live send move (a following route's record with a ramp, written by a live commit or by a -/// rebuild's send join) and every landing a rebuild's removed send row starts, folded into one +/// rebuild's send join) and every landing at once a rebuild's carry starts, folded into one /// bound (B19-R7): the newest revision among them and the longest ramp any of them takes. A move /// is in effect from at most the watermark's `first_sample` once the watermark covers its revision /// (the watermark is monotone), so every move has ended once the watermark covers `revision` and @@ -690,15 +690,12 @@ fn fold_send_move(window: &mut Option, revision: u64, ramp: u32) }); } -/// The longest length a removed send row's landing takes at its successor's first block (B19-R7): -/// a following route's `SendGain`, `SendMatrix` and mute lengths are the session's fader, pan and -/// mute lengths, each at most the route ramp bound. +/// The length a landing at once takes at its successor's first block (B19-R7): a carried +/// following route's column that lost a follow curve (its own source mute or a reaching VCA mute) +/// or that composes otherwise lands over the `Mute` or `VcaMute` row's length, both the session's +/// mute length. fn send_landing_ramp(model: &session::SessionModel) -> u32 { - let ramps = controls::LiveRamps::for_session(model); - ramps - .fader_samples - .max(ramps.mute_samples) - .max(ramps.pan_samples) + controls::LiveRamps::for_session(model).for_row(controls::LiveRampRow::Mute) } /// The stable key of one observation tap, borrowed from the bytes that name it (#1677): the strip's @@ -2604,8 +2601,8 @@ where // // B19-R8 (TBD54 deletes this; it widens B19-R7's removed-curve term): a successor whose // send join carries a following route into a following route may land a carried column - // at once at the swap (a removed row or source mute curve, or a column that composes with - // no placed value), cutting a live send move still ramping there, which render cannot + // at once at the swap (a removed follow curve, or a column that composes otherwise), + // cutting a live send move still ramping there, which render cannot // report. Control cannot tell which column lands at once or whether its move is in // flight, so such a rebuild completes as the transition fallback whenever any live send // move may still ramp: the transaction's own join records with a ramp (MAJOR-2: they are @@ -2623,7 +2620,7 @@ where ) })); // The rebuild's own send moves join the window with the commit: its join's records, and - // a landing at once at the swap (a removed row's, or one a flagged rebuild may make, + // a landing at once at the swap (a removed curve's, or one a flagged rebuild may make, // B19-R9), so a second rebuild inside that landing is flagged too. let rebuild_send_ramp = match &kind { Rebuild::Transaction(prepared) if follow_loses_a_curve || send_fallback => { @@ -3227,9 +3224,8 @@ where // 2a. Each route's producer, by route ID (#1557 D4): a binary search over the canonical // 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 set (flags bits 4-11: the - // source lanes and the route rows, #1654 D2) differs from the plan's (render takes it from - // its own cursors), is an internal fault, found before the first write, so no ack + // producer of its kind, or a seven-word record whose automated lanes (flags bits 4-5) + // 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; diff --git a/crates/control-plane/tests/send_automation_realtime.rs b/crates/control-plane/tests/send_automation_realtime.rs deleted file mode 100644 index 7e2e3659e..000000000 --- a/crates/control-plane/tests/send_automation_realtime.rs +++ /dev/null @@ -1,355 +0,0 @@ -//! #1654 gate 6 (T4): a send whose own rows have stored automation renders allocation-free -//! through the shared commit, across its hand edits. -//! -//! The 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` into bus `b` (no source -//! mute curve), and the send rides its `gain_db` (row 1, linear from 0 to -12 dB) and its -//! `matrix_lr` (row 4). A hand move of the automated gain (`SET_CONTROL` row 1, the hand offset, -//! `live`), a hand edit of the automated `matrix_lr` (automation wins: `model_only`), a live edit -//! of the un-automated `matrix_rr` (D6, `live`) and a hand send mute (`live`) commit in the -//! middle of the ride. Every render call after the warm-up allocates and frees nothing, measured -//! with the audited allocator's thread-scoped counters. - -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"; - -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 `b-main` -/// takes the bus to the output, so the output's right plane is the send. The send rides its -/// `gain_db` from 0 to -12 dB and its `matrix_lr` from 0 to 0.5 over 8,192 samples. -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 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), - ), - route( - "eq0-b", - RouteSource::Track { - track_id: id("eq0"), - tap: SendTap::PreFader, - }, - RouteDestination::SubmixInput { submix_id: id("b") }, - matrix(0.0, 1.0), - ), - 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 ride = |name: &str, control: u32, to: f32, unit| Automation { - id: id(name), - target: ControlAddress { - owner: Owner::Route(id("eq0-b")), - section: Section::Own, - control, - lane: ParameterChannel::Both, - }, - segments: vec![AutomationSegment { - shape: AutomationShape::Linear, - start_sample: 0, - end_sample: 8_192, - start_value: 0.0, - end_value: to, - unit, - }], - offset: AutomationOffset::default(), - }; - model.automation = vec![ - ride("send-gain", 1, -12.0, ParameterUnit::Db), - ride("send-lr", 4, 0.5, ParameterUnit::Linear), - ]; - ( - 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 route row `control` of the send at `value` as one transaction, which must answer - /// `path`. - fn commit(&mut self, control: u32, value: f32, path: EditPath) { - let address = ControlAddress { - owner: Owner::Route(id("eq0-b")), - section: Section::Own, - control, - lane: ParameterChannel::Both, - }; - 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 control commits"); - let mut fields = [0_u16; 64]; - let found = 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 control is refused: {payload:?}") - } - DecodedTypedResponseFrame::Success { .. } => panic!("another success payload"), - }; - assert_eq!(found, path, "row {control}: the commit's path"); - self.revision += 1; - while self - .session - .dequeue_event(EventLane::Reliable, 65_536) - .expect("an event") - .is_some() - {} - } -} - -/// Gate 6. Every render block after the warm-up allocates and frees nothing, across a hand move -/// of the automated send gain at block 6 (`live`: the hand offset), a hand edit of the automated -/// `matrix_lr` at block 8 (`model_only`: automation wins), a live edit of the un-automated -/// `matrix_rr` at block 10 (D6) and a hand send mute at block 14 (`live`). Not vacuous: the send -/// (the output's right plane) carries audio before the mute and is silent once its ramp has ended. -/// -/// Test value: red if the seven-word cell of a route-row-only send, its record writes or the -/// commit's widened pre-write check allocate or free on render, if a hand move of the automated -/// gain or of an un-automated row is not live, if a hand edit of the automated matrix row is -/// acked live, or if the hand mute does not close the send. -#[test] -fn a_send_with_stored_row_automation_renders_allocation_free() { - bench_support::alloc::assert_installed(); - let mut rig = Rig::new(); - for _ in 0..2 { - rig.block(); - } - let mut open_before = false; - let mut silent_after = false; - while rig.block < 32 { - match rig.block { - 6 => rig.commit(1, 3.0, EditPath::Live), - 8 => rig.commit(4, 0.75, EditPath::ModelOnly), - 10 => rig.commit(6, 0.5, EditPath::Live), - 14 => rig.commit(2, 1.0, EditPath::Live), - _ => {} - } - 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 == 12 { - open_before = right.iter().any(|sample| *sample != 0.0); - } - if block == 30 { - silent_after = right.iter().all(|sample| *sample == 0.0); - } - } - assert!(open_before, "the send carries audio before the hand mute"); - assert!(silent_after, "the hand mute closes the send"); -} diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index 506bfb5f5..6a26c6d36 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -15,7 +15,6 @@ use crate::effect_rows::{has_rows, session_unit}; use crate::error::ControlError; use crate::lane::Side; use crate::registry::resolve; -use crate::route::{ROUTE_AUTOMATED_ROWS, ROUTE_GAIN_DB}; use crate::vca::{VCA_MUTE, VCA_OFFSET_DB}; use session::Lane; use std::collections::BTreeMap; @@ -49,35 +48,27 @@ fn effect_has_no_rows(model: &SessionModel, target: &ControlAddress) -> bool { } /// Whether the row at `target` takes a stored hand-move offset (#1650 D2, root's ruling R17-6): a -/// strip's builtin `fader_db`, a route's `gain_db` (row 1, [`route_gain_row`], #1654) and a VCA's -/// `offset_db` (row 1, [`vca_offset_row`], per lane, #1655 D5 and root's ruling of 2026-10-14: a -/// level control). Only a route into a submix can hold an accepted entry on the route row (an -/// output route's row is not automatable and is refused at `.target` first, D8), so the predicate -/// needs no model. The slice that renders the input trim (#1658) adds its row here. +/// strip's builtin `fader_db` and a VCA's `offset_db` (row 1, [`vca_offset_row`], per lane, #1655 +/// D5 and root's ruling of 2026-10-14: a level control). The slices that render the input trim +/// (#1658) and the send gain (#1654) add their rows here. pub(crate) fn takes_offset(target: &ControlAddress) -> bool { - fader_row(target) || route_gain_row(target) || vca_offset_row(target) + fader_row(target) || vca_offset_row(target) } /// The offset rule of one entry whose target is accepted (#1650 D2), per lane, left then right: /// on a lane that takes no offset (the entry does not drive it, or its row takes none) the /// offset is exactly `+0.0`, else `control.automation.offset`; on a driven lane of a row that -/// takes one, the offset lies in the row's own domain (`fader_db`, a send's `gain_db` and a VCA's -/// `offset_db`: `[-144, 24]` dB), else `control.domain`. `-0.0` is a valid zero only on a lane that takes an -/// offset, so a lane without one has one spelling. -/// -/// A shared row (a send's `gain_db`, R17-6) holds one offset in two fields: `left_db` is judged -/// against the domain, and `right_db` is `control.automation.offset` when its bits differ from -/// `left_db`'s (so `+0.0` and `-0.0` differ), else accepted with it. +/// takes one, the offset lies in the row's own domain (`fader_db` and a VCA's `offset_db`: +/// `[-144, 24]` dB, the VCA offset domain), else `control.domain`. `-0.0` is a valid zero only on +/// a lane that takes an offset, so a lane without one has one spelling. fn offset_faults( automation: &Automation, descriptor: &ControlDescriptor, ) -> impl Iterator { let target = &automation.target; let takes = takes_offset(target); - let shared = descriptor.lanes == Lanes::Shared; - let left = automation.offset.left_db; [ - (Lane::Left, left, ".offset.left_db"), + (Lane::Left, automation.offset.left_db, ".offset.left_db"), (Lane::Right, automation.offset.right_db, ".offset.right_db"), ] .into_iter() @@ -85,10 +76,7 @@ fn offset_faults( let driven = target.lane == lane || target.lane == Lane::Both; let fault = if !(takes && driven) { (value.to_bits() != 0.0_f32.to_bits()).then_some(ControlError::AutomationOffset) - } else if shared && lane == Lane::Right { - (value.to_bits() != left.to_bits()).then_some(ControlError::AutomationOffset) } else { - let lane = if shared { Lane::Both } else { lane }; check(descriptor, lane, value, None) .err() .map(|_| ControlError::Domain) @@ -129,11 +117,10 @@ pub struct ControlDiagnostic { /// `input_filter_order`). It is reported once per lane, at the LPF entry's segment when the LPF /// is automated, else at the HPF entry's; /// - `control.automation.offset`: a hand-move offset (#1650 D2) that is not `+0.0` on a lane the -/// entry does not drive, or on any lane of a row that takes no offset (only `fader_db`, a -/// send's `gain_db` and a VCA's `offset_db` take one, R17-6, #1655 D5), or a send `gain_db` entry whose `right_db` bits differ -/// from its `left_db` (the shared row holds one offset); `control.domain` for an offset outside -/// the row's domain. Each is reported at `.offset.left_db` or `.offset.right_db`, after the -/// entry's segments. +/// entry does not drive, or on any lane of a row that takes no offset (only `fader_db` and a +/// VCA's `offset_db` take one, #1655 D5); `control.domain` for an offset outside the row's +/// domain. Each is reported at `.offset.left_db` or `.offset.right_db`, after the entry's +/// segments. /// /// A target is reported once, at `.target`; its segments are judged only when the target is /// accepted. A segment is reported once, at `.segments[]`: a domain fault first, else a shape @@ -404,20 +391,20 @@ pub const MUTE_CELL_RULE: CellRule = CellRule { seek: SeekMode::Exact, }; -/// Whether a stored automation of `target` renders (#1646, #1647, #1651, #1654, #1655): the fader -/// row of a strip (builtin row 5, [`fader_row`]), its mute row (builtin row 6, [`mute_row`]), a -/// send's rows 1-6 (`gain_db`, `mute`, `matrix_*`, [`route_row`]) and a VCA's rows 1 and 2 -/// (`offset_db`, [`vca_offset_row`], and `mute`, [`vca_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. +/// Whether a stored automation of `target` renders (#1646, #1647, #1651, #1655): the fader row of +/// a strip (builtin row 5, [`fader_row`]), its mute row (builtin row 6, [`mute_row`]) and a VCA's +/// rows 1 and 2 (`offset_db`, [`vca_offset_row`], and `mute`, [`vca_mute_row`]). An entry on any +/// other row (a send's rows among them, #1654) 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): neither the mute row, -/// a route row nor a VCA row gives a lane a fader cell or a fader hand-move offset. A VCA ride +/// A reader that means "the fader" tests [`fader_row`] instead (#1651 T2): neither the mute row +/// nor a VCA row gives a lane a fader cell or a fader hand-move offset. A VCA ride /// makes its members' lanes event lanes ([`fader_event_lanes`], [`mute_event_lanes`]); it does not /// automate a member's own fader. #[must_use] pub fn automation_row_renders(target: &ControlAddress) -> bool { - fader_row(target) || mute_row(target) || route_row(target) || vca_row(target) + fader_row(target) || mute_row(target) || vca_row(target) } /// Whether `target` is a VCA's `offset_db` row (row 1, #1655): per lane, a level control that @@ -516,76 +503,6 @@ fn event_lanes<'a>( lanes } -/// Whether `target` is one of a route's rendered rows 1-6 (`gain_db`, `mute`, `matrix_ll`, -/// `matrix_lr`, `matrix_rl`, `matrix_rr`; #1654 D3). Row 7 `follows_mute` is not automatable -/// (#1632) and is not one. The predicate reads no model: an entry on a route into the output is -/// refused at `.target` before it is judged (D8), so an accepted entry it admits is a send's. -#[must_use] -pub fn route_row(target: &ControlAddress) -> bool { - matches!(target.owner, Owner::Route(_)) - && target.section == Section::Own - && ROUTE_AUTOMATED_ROWS.contains(&target.control) -} - -/// Whether `target` is a route's `gain_db` row (row 1): the one route row that takes a hand-move -/// offset (root's ruling R17-6). The send's offset readers and writers test this predicate. -#[must_use] -pub fn route_gain_row(target: &ControlAddress) -> bool { - matches!(target.owner, Owner::Route(_)) - && target.section == Section::Own - && target.control == ROUTE_GAIN_DB -} - -/// The stored automation entries that drive route `route`'s rendered rows, in row order -/// (`[gain_db, mute, matrix_ll, matrix_lr, matrix_rl, matrix_rr]`, rows 1-6, #1654): the first -/// entry per row in table order. Every route row is shared, so an entry drives its row whole. The -/// one enumeration that preparation, the classifier and owner edits read. An empty table returns -/// at once. -#[must_use] -pub fn route_automation<'a>( - model: &'a SessionModel, - route: &StableId, -) -> [Option<&'a Automation>; 6] { - let mut rows = [None; 6]; - for automation in &model.automation { - let target = &automation.target; - if !route_row(target) || !matches!(&target.owner, Owner::Route(owner) if owner == route) { - continue; - } - if let Some(row) = ROUTE_AUTOMATED_ROWS - .iter() - .position(|&control| control == target.control) - && rows[row].is_none() - { - rows[row] = Some(automation); - } - } - rows -} - -/// The rendered rows of route `route` (in [`route_automation`]'s order) that both models -/// automate with the same table, by the rule of [`continued_fader_lanes`]: such a row's cell -/// continues its carried event state verbatim, and every other carried route cell is repositioned -/// at adoption (#1654, Carry). -#[must_use] -pub fn continued_route_rows( - current: &SessionModel, - next: &SessionModel, - route: &StableId, -) -> [bool; 6] { - if current.automation.is_empty() || next.automation.is_empty() { - return [false; 6]; - } - let (before, after) = ( - route_automation(current, route), - route_automation(next, route), - ); - core::array::from_fn(|row| { - matches!((before[row], after[row]), (Some(before), Some(after)) - if same_segments(before, after)) - }) -} - /// 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] diff --git a/crates/controls/src/lib.rs b/crates/controls/src/lib.rs index 9575cfefb..ba7271f9b 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -47,11 +47,10 @@ 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, - continued_mute_lanes, continued_route_rows, fader_automation, fader_event_lanes, fader_row, + continued_mute_lanes, fader_automation, fader_event_lanes, fader_row, filter_statics_changed_under_automation, filter_under_automation, mute_automation, - mute_event_lanes, mute_row, rendered_automation_changed, route_automation, route_gain_row, - route_row, stored_segments, vca_mute_automation, vca_mute_row, vca_offset_automation, - vca_offset_row, + mute_event_lanes, mute_row, rendered_automation_changed, stored_segments, vca_mute_automation, + vca_mute_row, vca_offset_automation, vca_offset_row, }; 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 6037e3250..6411c46d9 100644 --- a/crates/controls/src/mask.rs +++ b/crates/controls/src/mask.rs @@ -51,11 +51,7 @@ struct Target<'a> { /// 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. A send whose `gain_db` a route-gain entry ([`crate::route_gain_row`]) -/// of `into` drives is masked the same way (R17-6): the entry's offset is copied and the route's -/// static `gain_db` is left as `into` holds it. A send's automated `mute` and matrix rows keep their -/// static values as control values (a hand mute ORs with the curve; a matrix fallback is stored by -/// a hand edit), so they are copied. A VCA lane whose `offset_db` a VCA-offset entry +/// a difference of structure. A VCA lane whose `offset_db` a VCA-offset entry /// ([`crate::vca_offset_row`]) of `into` drives is masked the same way (#1655 D5): the entry's /// offset is copied and the VCA's static offset of that lane is left as `into` holds it. A VCA's /// automated `mute` keeps its static (hand) mute as a control value, which render ORs with the @@ -119,21 +115,9 @@ pub fn mask_values(into: &mut SessionModel, from: &SessionModel) { ); } } - // The routes whose `gain_db` a route-gain entry of `into` drives (R17-6): their static gain is - // the fallback, not a control value, and stays as `into` holds it. - let gain_driven: Vec = if into.automation.is_empty() { - Vec::new() - } else { - into.routes - .iter() - .map(|route| crate::automation::route_automation(into, &route.id)[0].is_some()) - .collect() - }; for (index, route) in into.routes.iter_mut().enumerate() { if let Some(source) = pair(&from.routes, index, |candidate| candidate.id == route.id) { - if !gain_driven.get(index).copied().unwrap_or(false) { - route.gain_db = source.gain_db; - } + route.gain_db = source.gain_db; route.mute = source.mute; route.channel_matrix = source.channel_matrix.clone(); route.follows_mute = source.follows_mute; @@ -179,11 +163,10 @@ fn pair(items: &[T], index: usize, matches: impl Fn(&T) -> bool) -> Option<&T } /// Copy the hand-move offset of every entry on a row that takes one (a fader-row entry, -/// [`crate::fader_row`], a send-gain entry, [`crate::route_gain_row`], R17-6, and a VCA-offset -/// entry, [`crate::vca_offset_row`], #1655 D5) 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, or of the send's `gain_db`. An entry -/// on another rendered row (a mute row, a send matrix row) carries no offset, so it is not masked. +/// [`crate::fader_row`], and a VCA-offset entry, [`crate::vca_offset_row`], #1655 D5) 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 row of the lanes the entry drives. An entry on another rendered row (a mute +/// row) 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::takes_offset(&entry.target) { diff --git a/crates/controls/src/owner_edits.rs b/crates/controls/src/owner_edits.rs index cc8d2c2b1..2ab06a2b8 100644 --- a/crates/controls/src/owner_edits.rs +++ b/crates/controls/src/owner_edits.rs @@ -10,9 +10,8 @@ //! that drives the lane (#1650 D3), so that entry is an owner of the edit too (root's ruling //! R17-1 = A): a `fader_db` write clones the rendered entries that drive the strip's fader lanes //! with the strip, and [`crate::set`] on the owners model reads and writes them as it would on the -//! whole model. A send's `gain_db` takes the same rule (root's ruling R17-6): a write to route row -//! 1 clones the rendered entry that drives it with the route; so does a VCA's `offset_db` (#1655 -//! D5): a write to VCA row 1 clones the rendered entries that drive its lanes with the VCA. +//! whole model. A VCA's `offset_db` takes the same rule (#1655 D5): a write to VCA row 1 clones +//! the rendered entries that drive its lanes with the VCA. use session::{OwnerSlots, SessionModel}; @@ -97,11 +96,6 @@ impl<'m> OwnerEdits<'m> { { self.take_fader_automation(strip); } - if let Owner::Route(route) = &address.owner - && crate::automation::route_gain_row(address) - { - self.take_route_gain_automation(route); - } if let Owner::Vca(vca) = &address.owner && crate::automation::vca_offset_row(address) { @@ -141,18 +135,6 @@ impl<'m> OwnerEdits<'m> { } } - /// Clone the rendered entry that drives route `route`'s `gain_db` into the owners model, in ID - /// order, unless it is there already (R17-6, R17-1 = A on the send gain). A scan of the - /// committed table, with an early out when it is empty. - fn take_route_gain_automation(&mut self, route: &session::StableId) { - if self.committed.automation.is_empty() { - return; - } - if let Some(entry) = crate::automation::route_automation(self.committed, route)[0] { - insert_sorted(&mut self.owners.automation, entry, |entry| &entry.id); - } - } - /// Clone the owner into the owners model, in ID order, unless it is there already. fn take(&mut self, owner: &Owner) -> Result<(), OwnerEditError> { let unknown = OwnerEditError::Control(ControlError::AddressUnknown); diff --git a/crates/controls/src/registry.rs b/crates/controls/src/registry.rs index 84a4b5450..8306bd4a9 100644 --- a/crates/controls/src/registry.rs +++ b/crates/controls/src/registry.rs @@ -24,13 +24,11 @@ pub struct ControlRow { /// 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): the `fader_db` row, - /// since #1651 (root ruling R18-2) the `mute` row, since #1654 a send's rows 1-6, and since - /// #1655 a VCA's `offset_db` and `mute`. On such a lane the `fader_db` row's value is the - /// driving entry's hand-move offset, not the static fader, and so are a send's `gain_db` - /// (R17-6) and a VCA's `offset_db` (#1655 D5); the `mute` rows' values stay the static (hand) - /// mute, which render ORs with the curve (OQ1 = C), and a send matrix row's value is its - /// static fallback (automation wins). A VCA ride does not mark its members' rows: they list - /// their own entries only. + /// since #1651 (root ruling R18-2) the `mute` row, and since #1655 a VCA's `offset_db` and + /// `mute`. On such a lane the `fader_db` row's value is the driving entry's hand-move offset, + /// not the static fader, and so is a VCA's `offset_db` (#1655 D5); the `mute` rows' values stay + /// the static (hand) mute, which render ORs with the curve (OQ1 = C). A VCA ride does not mark + /// its members' rows: they list their own entries only. pub automated: bool, } @@ -243,13 +241,7 @@ pub fn owner_rows_in( ); } OwnerSlot::Route(index) => { - let route = view.route(index); - route::push_rows( - out, - route, - view.route_automation(&route.id), - effective.route_gate(index), - ); + route::push_rows(out, view.route(index), effective.route_gate(index)); } OwnerSlot::Vca(index) => { let vca = view.vca(index); @@ -601,10 +593,6 @@ fn write( effect_rows::write(model, address, value) } (Owner::Route(id), Section::Own) => { - // An automated send gain's value is its entry's hand-move offset (R17-6). - if control == route::ROUTE_GAIN_DB && route::write_gain_offset(model, id, value) { - return Ok(()); - } let target = model .routes .iter_mut() diff --git a/crates/controls/src/route.rs b/crates/controls/src/route.rs index 9e92c4901..77edf156c 100644 --- a/crates/controls/src/route.rs +++ b/crates/controls/src/route.rs @@ -6,8 +6,8 @@ use effect_contract::{ParameterLattice, ParameterUnit}; use session::{ - Automation, ROUTE_COEFFICIENT_MAGNITUDE_MAXIMUM, ROUTE_GAIN_DB_MAXIMUM, ROUTE_GAIN_DB_MINIMUM, - Route, RouteDestination, SessionModel, StableId, + ROUTE_COEFFICIENT_MAGNITUDE_MAXIMUM, ROUTE_GAIN_DB_MAXIMUM, ROUTE_GAIN_DB_MINIMUM, Route, + RouteDestination, SessionModel, StableId, }; use crate::descriptor::{ @@ -15,7 +15,7 @@ use crate::descriptor::{ }; use crate::error::ControlError; use crate::lane::{Side, toggle}; -use crate::registry::{ControlRow, Resolved, Source, push_control_automated}; +use crate::registry::{ControlRow, Resolved, Source, push_control}; use crate::{ControlClass, ControlKind, Family, LiveRampRow, RebuildReason, Taper}; use session::{Owner, Section}; @@ -26,12 +26,6 @@ const MATRIX_LR: u32 = 4; const MATRIX_RL: u32 = 5; const MATRIX_RR: u32 = 6; const FOLLOWS_MUTE: u32 = 7; -/// The ID of the route's `gain_db` row, the one route row that takes a hand-move offset (R17-6). -pub(crate) const ROUTE_GAIN_DB: u32 = GAIN_DB; -/// The route rows a stored automation renders on a send, in their cell order (#1654 D3): rows 1-6. -/// Row 7 `follows_mute` is not automatable (#1632). -pub(crate) const ROUTE_AUTOMATED_ROWS: [u32; 6] = - [GAIN_DB, MUTE, MATRIX_LL, MATRIX_LR, MATRIX_RL, MATRIX_RR]; /// The own-row ID of `effective_gate`. const EFFECTIVE_GATE: u32 = 64; @@ -236,72 +230,33 @@ fn descriptor(row: &RouteRow, route: &Route) -> ControlDescriptor { } } -/// The stored automation entries that drive a route's rendered rows -/// ([`crate::route_automation`], or [`crate::ModelView::route_automation`]), in row order. -pub(crate) type RouteEntries<'a> = [Option<&'a Automation>; 6]; - -/// Where the rendered row `control` sits in [`RouteEntries`], or `None` for a row no entry renders. -fn automated_index(control: u32) -> Option { - ROUTE_AUTOMATED_ROWS.iter().position(|&row| row == control) -} - -/// The committed value of a stored row: the row's model field, except that an automated `gain_db` -/// reads its entry's hand-move offset (R17-6; the static gain is the fallback, not heard). -fn stored_value( - read: fn(&Route) -> f32, - route: &Route, - control: u32, - entries: RouteEntries, -) -> f32 { - match entries[0] { - Some(entry) if control == GAIN_DB => entry.offset.left_db, - _ => read(route), - } -} - -/// Append every control row of one route. `entries` are the route's driving entries, `gate` its -/// effective gate per source lane. A row an entry drives lists `automated = true` (#1650 D4 on the -/// send); an automated `gain_db` lists its entry's offset as its value, an automated `mute` or -/// matrix row its static (hand or fallback) value. -pub(crate) fn push_rows( - out: &mut Vec, - route: &Route, - entries: RouteEntries, - gate: [bool; 2], -) { - push_route_rows(out, route, entries, gate, false); +/// Append every control row of one route. `gate` is the route's effective gate, per source lane. +pub(crate) fn push_rows(out: &mut Vec, route: &Route, gate: [bool; 2]) { + push_route_rows(out, route, gate, false); } /// Append only a route's derived rows (its effective gate), in listing order. pub(crate) fn push_derived_rows(out: &mut Vec, route: &Route, gate: [bool; 2]) { - push_route_rows(out, route, [None; 6], gate, true); + push_route_rows(out, route, gate, true); } -fn push_route_rows( - out: &mut Vec, - route: &Route, - entries: RouteEntries, - gate: [bool; 2], - derived_only: bool, -) { +fn push_route_rows(out: &mut Vec, route: &Route, gate: [bool; 2], derived_only: bool) { let owner = Owner::Route(route.id.clone()); for row in &ROWS { let descriptor = descriptor(row, route); if derived_only && descriptor.class != ControlClass::Derived { continue; } - let automated = automated_index(row.id).is_some_and(|index| entries[index].is_some()); - push_control_automated( + push_control( out, &owner, &Section::Own, row.id, &descriptor, |side| match &row.value { - RouteValue::Stored { read, .. } => stored_value(*read, route, row.id, entries), + RouteValue::Stored { read, .. } => read(route), RouteValue::Derived { read } => read(gate, side), }, - |_| automated, ); } } @@ -322,15 +277,8 @@ pub(crate) fn resolve( .find(|row| row.id == control) .ok_or(ControlError::AddressUnknown)?; let descriptor = descriptor(row, route); - let entries = if control == GAIN_DB { - crate::automation::route_automation(model, id) - } else { - [None; 6] - }; Ok(match &row.value { - RouteValue::Stored { read, .. } => { - Resolved::stored(descriptor, |_| stored_value(*read, route, control, entries)) - } + RouteValue::Stored { read, .. } => Resolved::stored(descriptor, |_| read(route)), RouteValue::Derived { read } => Resolved { descriptor, source: Source::RouteDerived(*read), @@ -338,26 +286,6 @@ pub(crate) fn resolve( }) } -/// Write `value` as the hand-move offset (both fields, one value) of the entry of `model` that -/// drives route `route`'s `gain_db` (R17-6), and return whether one does; when none does, the -/// caller writes the static gain. The caller has run [`crate::check`] with the row's own domain, -/// `[-144, 24]` dB, which is the offset domain. -pub(crate) fn write_gain_offset(model: &mut SessionModel, route: &StableId, value: f32) -> bool { - if model.automation.is_empty() { - return false; - } - let Some(id) = - crate::automation::route_automation(model, route)[0].map(|entry| entry.id.clone()) - else { - return false; - }; - if let Some(entry) = model.automation.iter_mut().find(|entry| entry.id == id) { - entry.offset.left_db = value; - entry.offset.right_db = value; - } - true -} - /// Write one of a route's rows. pub(crate) fn write(route: &mut Route, control: u32, value: f32) -> Result<(), ControlError> { match ROWS.iter().find(|row| row.id == control) { diff --git a/crates/controls/src/view.rs b/crates/controls/src/view.rs index 21e6f6077..60033be83 100644 --- a/crates/controls/src/view.rs +++ b/crates/controls/src/view.rs @@ -120,27 +120,6 @@ impl<'a> ModelView<'a> { }) } - /// The rendered stored automation entries that drive route `route`'s rows 1-6, in row order - /// ([`crate::route_automation`]), each the edited entry when the view holds one (R17-6, R17-1): - /// a hand move on an automated send gain writes the driving entry's offset, so the binding of - /// the route's `gain_db` row reads the entry here. Which entry drives a row is read from the - /// committed table, and only its offset from the edit, as for [`Self::fader_automation`]. - #[must_use] - pub fn route_automation(&self, route: &StableId) -> [Option<&'a Automation>; 6] { - if self.structure.automation.is_empty() { - return [None; 6]; - } - let rows = crate::automation::route_automation(self.structure, route); - if self.is_whole() { - return rows; - } - rows.map(|entry| { - entry.map(|entry| { - find(&self.owners.automation, &entry.id, |held| &held.id).unwrap_or(entry) - }) - }) - } - /// The rendered stored automation entries that drive VCA `vca`'s `offset_db`, per lane /// (`[left, right]`, [`crate::vca_offset_automation`]), each the edited entry when the view /// holds one (#1655 D5, R17-1): a hand move on an automated VCA offset writes the driving diff --git a/crates/controls/tests/automation.rs b/crates/controls/tests/automation.rs index d59b947fa..4a1536412 100644 --- a/crates/controls/tests/automation.rs +++ b/crates/controls/tests/automation.rs @@ -683,10 +683,9 @@ fn with_offset(automation: Automation, left_db: f32, right_db: f32) -> Automatio } /// #1650a gate 1 (D2, root's ruling R17-6): the hand-move offset belongs to a lane the entry -/// drives on a row that takes it (here `fader_db`; the send gain is -/// `the_send_gain_takes_one_shared_offset`), inside that row's `[-144, 24]` dB. A lane the entry -/// does not drive, and every lane of a row that takes none (the input trim until #1658, a pan), -/// hold exactly `+0.0`, else `control.automation.offset`; an +/// drives on the one row that takes it, `fader_db`, inside that row's `[-144, 24]` dB. A lane the +/// entry does not drive, and every lane of any other row (the input trim and the send gain until +/// #1658 and #1654 add them, a pan), hold exactly `+0.0`, else `control.automation.offset`; an /// offset outside the domain is `control.domain`. Each is reported at its lane's path, after the /// entry's segments. /// @@ -739,8 +738,7 @@ fn the_offset_belongs_to_a_driven_fader_lane() { "control.domain", ".offset.left_db", ), - // Rows that take no offset (R17-6): the trim, a pan; and a send gain whose two fields - // differ (its shared row holds one offset, #1654). + // Rows that take no offset (R17-6): the trim, a send's gain, a pan. ( with_offset( one( @@ -890,267 +888,6 @@ fn a_mute_entry_reaches_no_fader_only_reader() { assert_eq!(into.tracks[0].fader.right_db.to_bits(), (-9.0f32).to_bits()); } -const ROUTE_MUTE: u32 = 2; -const ROUTE_MATRIX_LL: u32 = 3; -const ROUTE_MATRIX_LR: u32 = 4; -const ROUTE_MATRIX_RR: u32 = 6; - -/// #1654 gate 8 (root's ruling R17-6): a send's `gain_db` takes one hand-move offset, held in both -/// fields of its shared row, inside `[-144, 24]` dB; `right_db` whose bits differ from `left_db`'s -/// is `control.automation.offset` at `.offset.right_db` (so `+0.0` with `-0.0` is refused, and an -/// out-of-domain pair is one `control.domain` at `.offset.left_db`). The send's `mute` and matrix -/// rows take no offset: a non-zero field there is `control.automation.offset`. -/// -/// Red if the send gain still takes no offset (the accepted cases are refused), if its two fields -/// may differ (render reads one offset, so the other field would be acked and never heard), if a -/// shared row's domain check uses a single lane (every accepted case turns `control.domain`), or -/// if the offset reaches the send's mute or matrix rows. -#[test] -fn the_send_gain_takes_one_shared_offset() { - let send = |name: &str, control: u32, unit: ParameterUnit, value: f32| { - one(name, route_own("send", control), unit, value) - }; - let gain = |name: &str| send(name, GAIN_DB, ParameterUnit::Db, -6.0); - for accepted in [ - with_offset(gain("plus-three"), 3.0, 3.0), - with_offset(gain("negative-zero"), -0.0, -0.0), - with_offset(gain("top"), 24.0, 24.0), - with_offset(gain("bottom"), -144.0, -144.0), - ] { - let name = accepted.id.as_str().to_owned(); - assert_eq!(judged(vec![accepted]), [], "{name}"); - } - let refused = [ - ( - with_offset(gain("zeros-differ"), 0.0, -0.0), - "control.automation.offset", - ".offset.right_db", - ), - ( - with_offset(gain("fields-differ"), 1.0, 2.0), - "control.automation.offset", - ".offset.right_db", - ), - ( - with_offset(gain("above"), 30.0, 30.0), - "control.domain", - ".offset.left_db", - ), - ( - with_offset( - send("matrix", ROUTE_MATRIX_LR, ParameterUnit::Linear, 0.5), - 0.5, - 0.0, - ), - "control.automation.offset", - ".offset.left_db", - ), - ( - with_offset( - send("mute", ROUTE_MUTE, ParameterUnit::Linear, 1.0), - 0.0, - -0.0, - ), - "control.automation.offset", - ".offset.right_db", - ), - ]; - for (entry, code, suffix) in refused { - let name = entry.id.as_str().to_owned(); - assert_eq!( - judged(vec![entry]), - [(code, format!("$.automation[id={name}]{suffix}"))], - "{name}" - ); - } -} - -/// #1654 D3: a send's rows 1-6 render ([`controls::automation_row_renders`]) and `follows_mute` -/// does not; only `gain_db` is the send's offset row; none reaches a fader-only reader -/// ([`controls::fader_row`]); [`controls::route_automation`] gives each row its first entry, in -/// row order; [`controls::automated_cell`] holds on a driven row; and -/// [`controls::continued_route_rows`] holds on a row whose table both models keep. -/// -/// Red if a send row stays masked from rendering, if `follows_mute` joins the list, if the route -/// enumeration misplaces a row, or if an entry of another route is read. -#[test] -fn send_rows_render_and_enumerate_in_row_order() { - use controls::{ - automated_cell, automation_row_renders, continued_route_rows, fader_row, route_automation, - route_gain_row, route_row, - }; - for control in [GAIN_DB, ROUTE_MUTE, ROUTE_MATRIX_LL, 5, ROUTE_MATRIX_RR] { - let target = route_own("send", control); - assert!(route_row(&target) && automation_row_renders(&target)); - assert!(!fader_row(&target)); - assert_eq!(route_gain_row(&target), control == GAIN_DB); - } - let follows = route_own("send", FOLLOWS_MUTE); - assert!(!route_row(&follows) && !automation_row_renders(&follows)); - - let mut model = effects_fixture(); - model.automation = vec![ - one( - "lr", - route_own("send", ROUTE_MATRIX_LR), - ParameterUnit::Linear, - 0.25, - ), - one("gain", route_own("send", GAIN_DB), ParameterUnit::Db, -6.0), - one( - "other", - route_own("bus-out", GAIN_DB), - ParameterUnit::Db, - -6.0, - ), - ]; - let rows = route_automation(&model, &id("send")); - let names = rows.map(|entry| entry.map(|entry| entry.id.as_str().to_owned())); - assert_eq!( - names, - [ - Some("gain".to_owned()), - None, - None, - Some("lr".to_owned()), - None, - None - ] - ); - assert!(automated_cell(&model, &route_own("send", ROUTE_MATRIX_LR))); - assert!(!automated_cell(&model, &route_own("send", ROUTE_MATRIX_LL))); - - let mut next = model.clone(); - next.automation[0].segments[0].start_value = 0.5; - next.automation[0].segments[0].end_value = 0.5; - assert_eq!( - continued_route_rows(&model, &next, &id("send")), - [true, false, false, false, false, false] - ); -} - -/// The `send` session with a gain ride and a `matrix_lr` ride on the send. -fn ridden_send() -> SessionModel { - let mut model = effects_fixture(); - model.automation = vec![ - one("gain", route_own("send", GAIN_DB), ParameterUnit::Db, -6.0), - one( - "lr", - route_own("send", ROUTE_MATRIX_LR), - ParameterUnit::Linear, - 0.25, - ), - ]; - model -} - -/// #1654 R17-6 and R17-1 at the controls level: a `SET_CONTROL` on an automated send gain writes -/// the driving entry's offset (both fields, one value) and leaves the static gain (the fallback); -/// `get` and the list report the offset as the row's value with `automated = true`; an automated -/// matrix row keeps automation-wins (the hand value is the stored fallback, listed `automated`); -/// an un-automated row lists `automated = false`. The in-place path ([`controls::OwnerEdits`], -/// read through [`controls::ModelView`]) gives the dense model and the dense rows of the whole -/// `set`. -/// -/// Red if a hand send gain move writes the static gain (automation wins, the move is acked and -/// never heard), if it writes one field only, if the list hides the offset or the flag, or if the -/// in-place path does not clone the entry with the route (its write would be lost on the owners -/// model). -#[test] -fn a_hand_move_on_an_automated_send_gain_writes_its_offset() { - use controls::{ - ModelView, MonitorOverlay, OwnerEdits, effective, get, owner_index, owner_rows_in, - owner_rows_with, set, - }; - let committed = ridden_send(); - let gain = route_own("send", GAIN_DB); - let lr = route_own("send", ROUTE_MATRIX_LR); - let ll = route_own("send", ROUTE_MATRIX_LL); - let route = |model: &SessionModel| { - model - .routes - .iter() - .find(|route| route.id == id("send")) - .cloned() - .expect("send") - }; - let static_gain = route(&committed).gain_db; - - let mut whole = committed.clone(); - set(&mut whole, &gain, 3.0, None, RATE).expect("gain"); - set(&mut whole, &lr, 0.75, None, RATE).expect("matrix"); - assert_eq!(whole.automation[0].offset.left_db, 3.0); - assert_eq!(whole.automation[0].offset.right_db, 3.0); - assert_eq!(route(&whole).gain_db.to_bits(), static_gain.to_bits()); - assert_eq!(route(&whole).channel_matrix.lr, 0.75); - let overlay = MonitorOverlay::default(); - assert_eq!(get(&whole, &overlay, &gain, RATE), Ok(3.0)); - let gain_row = common::row_of(&whole, &gain); - assert!(gain_row.automated && gain_row.value == 3.0); - let lr_row = common::row_of(&whole, &lr); - assert!(lr_row.automated && lr_row.value == 0.75); - assert!(!common::row_of(&whole, &ll).automated); - - // A send no entry drives keeps the static write. - let mut plain = effects_fixture(); - set(&mut plain, &gain, 3.0, None, RATE).expect("plain gain"); - assert_eq!(route(&plain).gain_db, 3.0); - assert!(!common::row_of(&plain, &gain).automated); - - // The in-place path: the same writes on the owners model. - let mut edits = OwnerEdits::new(&committed); - edits.set(&gain, 3.0, None, RATE).expect("in-place gain"); - edits.set(&lr, 0.75, None, RATE).expect("in-place matrix"); - let (owners, slots) = edits.finish(); - assert_eq!(owners.automation.len(), 1, "the gain entry alone is cloned"); - assert_eq!(slots.automation, [(0, 0)]); - let view = ModelView::edited(&committed, &owners); - assert_eq!(view.to_model(), whole); - assert_eq!( - view.route_automation(&id("send"))[0].map(|entry| entry.offset.left_db), - Some(3.0) - ); - let owner = owner_index(&committed, &controls::Owner::Route(id("send"))).expect("send"); - let dense = effective(&whole, &overlay); - let (mut dense_rows, mut view_rows) = (Vec::new(), Vec::new()); - owner_rows_with(&whole, &dense, &overlay, owner, RATE, &mut dense_rows); - owner_rows_in(view, &dense, &overlay, owner, RATE, &mut view_rows); - assert_eq!(view_rows, dense_rows); -} - -/// #1654 mask treatment: [`controls::mask_values`] copies a send-gain entry's offset as a control -/// value and keeps the static gain of an automated send as structure (its fallback), while an -/// un-automated send's gain and an automated send's matrix fallback stay control values. -/// -/// Red if a send offset change is seen as structure (a live hand move would rebuild), or if a -/// change of an automated send's static gain is hidden as a value (it would never rebuild). -#[test] -fn the_mask_copies_a_send_offset_and_keeps_its_fallback() { - use controls::mask_values; - let mut from = ridden_send(); - from.automation[0].offset.left_db = 2.0; - from.automation[0].offset.right_db = 2.0; - for route in &mut from.routes { - route.gain_db = -9.0; - route.channel_matrix.lr = 0.5; - } - let mut into = ridden_send(); - let kept = into - .routes - .iter() - .find(|route| route.id == id("send")) - .expect("send") - .gain_db; - mask_values(&mut into, &from); - assert_eq!(into.automation[0].offset.left_db, 2.0); - assert_eq!(into.automation[0].offset.right_db, 2.0); - for route in &into.routes { - let expected = if route.id == id("send") { kept } else { -9.0 }; - assert_eq!(route.gain_db.to_bits(), expected.to_bits(), "{}", route.id); - assert_eq!(route.channel_matrix.lr, 0.5); - } -} - const VCA_OFFSET_DB: u32 = 1; const VCA_MUTE: u32 = 2; diff --git a/crates/graph-compiler/src/ids.rs b/crates/graph-compiler/src/ids.rs index b1cd7bbd0..cbbdc0e9e 100644 --- a/crates/graph-compiler/src/ids.rs +++ b/crates/graph-compiler/src/ids.rs @@ -291,10 +291,7 @@ pub(crate) fn sidechain_matches( /// the gain checks below cannot fail (every gain in `[-144, 24]` dB converts to a normal finite /// `f32`); they stay as defence for this function's own contract. pub(crate) fn route_transform(gain_db: f32, matrix: [f32; 4]) -> Option { - // A static gain is the law with no offset. `-0.0` is the exact additive identity of `f64` - // (`x + -0.0` is `x` for every `x`, `-0.0` included), and the clamp is the identity on the - // route domain `route_values` checks, so this is `db_to_gain_f32(gain_db)` bit for bit. - let gain = route_gain_law(gain_db, -0.0); + let gain = math::db_to_gain_f32(gain_db); let [ll, lr, rl, rr] = matrix; (gain_db.is_finite() && gain.is_finite() @@ -309,31 +306,6 @@ pub(crate) fn route_transform(gain_db: f32, matrix: [f32; 4]) -> Option f32 { - let sum = (f64::from(curve_db) + f64::from(offset_db)).clamp( - f64::from(session::ROUTE_GAIN_DB_MINIMUM), - f64::from(session::ROUTE_GAIN_DB_MAXIMUM), - ); - // The one rounding of the law: the clamped sum is in the `f32` route domain. - math::db_to_gain_f32(sum as f32) -} -// REALTIME_POLICY_END - /// The unfolded transform of a route whose values are in domain (issue #1237 D2), then /// `route_transform`'s checks. It backs [`route_coefficients`] and the compiler's lowering, so both /// refuse exactly the same values and the transform is derived once. The domain is the session @@ -459,83 +431,3 @@ pub(crate) fn diag(code: &'static str, path: &str) -> GraphDiagnostic { cycle_edge_paths: Vec::new(), } } - -#[cfg(test)] -mod gain_law_tests { - //! #1654 T2: the send gain law and the static route gain are one derivation. - use super::*; - - /// Gain values across the route domain: both zeros, the bounds, a negative and positive range - /// and values that are not exact in binary. - fn domain_values() -> Vec { - let mut values = vec![ - 0.0, - -0.0, - session::ROUTE_GAIN_DB_MINIMUM, - session::ROUTE_GAIN_DB_MAXIMUM, - -6.0, - 3.0, - 0.1, - -0.1, - f32::from_bits(1), - -f32::from_bits(1), - f32::MIN_POSITIVE, - ]; - let mut db = session::ROUTE_GAIN_DB_MINIMUM; - while db <= session::ROUTE_GAIN_DB_MAXIMUM { - values.push(db); - db += 0.37; - } - values - } - - /// A flat curve at `v` with an offset of `+0.0` or `-0.0` gives the bits of the static gain - /// `v` (`route_values`, the plan's lowering) and of `db_to_gain_f32(v)`, the conversion before - /// the law existed. - /// - /// Test value: red if the law rounds twice, adds the offset in `f32` with another result, or - /// lets the sign of a zero offset reach the gain, so a flat ride would not render the static - /// value bit for bit (gate 1). - #[test] - fn a_zero_offset_gives_the_static_gain_bits() { - let matrix = [1.0, 0.0, 0.0, 1.0]; - for value in domain_values() { - let fixed = route_values(value, matrix).expect("in domain").gain; - assert_eq!( - fixed.to_bits(), - math::db_to_gain_f32(value).to_bits(), - "static {value}" - ); - for offset in [0.0, -0.0] { - assert_eq!( - route_gain_law(value, offset).to_bits(), - fixed.to_bits(), - "curve {value}, offset {offset:?}" - ); - } - } - } - - /// The sum is taken in `f64`, rounded once and clamped to the route domain; a NaN stays NaN. - /// - /// Test value: red if the clamp is lost (a ride plus a hand offset past 24 dB would exceed the - /// route domain) or if the sum is rounded before it is clamped. - #[test] - fn the_law_sums_in_f64_and_clamps_to_the_route_domain() { - let top = math::db_to_gain_f32(session::ROUTE_GAIN_DB_MAXIMUM); - let bottom = math::db_to_gain_f32(session::ROUTE_GAIN_DB_MINIMUM); - assert_eq!(route_gain_law(20.0, 10.0).to_bits(), top.to_bits()); - assert_eq!(route_gain_law(-140.0, -10.0).to_bits(), bottom.to_bits()); - assert_eq!( - route_gain_law(-6.0, 3.0).to_bits(), - math::db_to_gain_f32(-3.0).to_bits() - ); - let (curve, offset) = (0.1_f32, 0.2_f32); - let once = (f64::from(curve) + f64::from(offset)) as f32; - assert_eq!( - route_gain_law(curve, offset).to_bits(), - math::db_to_gain_f32(once).to_bits() - ); - assert!(route_gain_law(f32::NAN, 0.0).is_nan()); - } -} diff --git a/crates/graph-compiler/src/lib.rs b/crates/graph-compiler/src/lib.rs index 604bdfac4..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, route_gain_law, route_values}; +pub use ids::{RouteValueError, route_coefficients, route_values}; #[cfg(test)] mod tests { diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index de889542d..570257bff 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1135,18 +1135,13 @@ impl GraphRouteControlProducer { /// 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; #1654 T2): 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]`, bit 6 `automated_gain`, bit 7 `automated_mute`, bits 8 to 11 -/// `automated_matrix[ll, lr, rl, rr]`. Every other bit is 0; [`Self::from_word`] refuses a word with -/// any of them set. +/// 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. Bits 6 to 11 are fixed by the plan too: whether -/// the route's own row 1 (`gain_db`), row 2 (`mute`) and rows 3 to 6 (the matrix) have a stored -/// curve (#1654 D2). When `automated_gain` is set, the cell's `gain` word holds the hand offset in -/// dB, not a linear gain (#1654, batch 10 amendment). +/// whether that source lane has a stored mute curve (its own or a reaching VCA's, #1655 D4). #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct RouteFollowFlags { /// The route's own mute: it silences the route whatever the source does. @@ -1157,12 +1152,6 @@ pub struct RouteFollowFlags { pub terms: [bool; 2], /// Per source lane `[L, R]`: whether the plan automates the lane's mute. pub automated: [bool; 2], - /// Whether the plan automates the route's own `gain_db` (row 1). - pub automated_gain: bool, - /// Whether the plan automates the route's own `mute` (row 2). - pub automated_mute: bool, - /// Per matrix entry `[ll, lr, rl, rr]` (rows 3 to 6): whether the plan automates it. - pub automated_matrix: [bool; 4], } impl RouteFollowFlags { @@ -1170,11 +1159,8 @@ impl RouteFollowFlags { const FOLLOWS_MUTE: u32 = 1 << 1; const TERMS: [u32; 2] = [1 << 2, 1 << 3]; const AUTOMATED: [u32; 2] = [1 << 4, 1 << 5]; - const AUTOMATED_GAIN: u32 = 1 << 6; - const AUTOMATED_MUTE: u32 = 1 << 7; - const AUTOMATED_MATRIX: [u32; 4] = [1 << 8, 1 << 9, 1 << 10, 1 << 11]; /// Every bit the layout defines; any other bit set makes a word no record wrote. - const DEFINED: u32 = (1 << 12) - 1; + const DEFINED: u32 = (1 << 6) - 1; /// The flags word. #[must_use] @@ -1188,16 +1174,10 @@ impl RouteFollowFlags { | bit(self.terms[1], Self::TERMS[1]) | bit(self.automated[0], Self::AUTOMATED[0]) | bit(self.automated[1], Self::AUTOMATED[1]) - | bit(self.automated_gain, Self::AUTOMATED_GAIN) - | bit(self.automated_mute, Self::AUTOMATED_MUTE) - | bit(self.automated_matrix[0], Self::AUTOMATED_MATRIX[0]) - | bit(self.automated_matrix[1], Self::AUTOMATED_MATRIX[1]) - | bit(self.automated_matrix[2], Self::AUTOMATED_MATRIX[2]) - | bit(self.automated_matrix[3], Self::AUTOMATED_MATRIX[3]) } // REALTIME_POLICY_BEGIN - /// The flags a word holds, or `None` when a bit above bit 11 is set. + /// 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 { @@ -1211,25 +1191,9 @@ impl RouteFollowFlags { word & Self::AUTOMATED[0] != 0, word & Self::AUTOMATED[1] != 0, ], - automated_gain: word & Self::AUTOMATED_GAIN != 0, - automated_mute: word & Self::AUTOMATED_MUTE != 0, - automated_matrix: [ - word & Self::AUTOMATED_MATRIX[0] != 0, - word & Self::AUTOMATED_MATRIX[1] != 0, - word & Self::AUTOMATED_MATRIX[2] != 0, - word & Self::AUTOMATED_MATRIX[3] != 0, - ], }) } - /// Per route row 1 to 6 (`gain_db`, `mute`, `ll`, `lr`, `rl`, `rr`), whether the plan - /// automates it: the order of [`GraphFollowRouteSource`]'s route cells. - #[must_use] - pub const fn route_rows(self) -> [bool; GRAPH_ROUTE_ROWS] { - let [ll, lr, rl, rr] = self.automated_matrix; - [self.automated_gain, self.automated_mute, ll, lr, rl, rr] - } - /// 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`] @@ -1246,29 +1210,6 @@ impl RouteFollowFlags { } } // REALTIME_POLICY_END - - /// The plan-fixed bits 4 to 11: the automated source lanes and route rows (#1654 D2). A - /// record written to a following route's cell carries the set its plan bound, or the commit - /// refuses it before any write (#1653 D5). - #[must_use] - pub const fn automated_set(self) -> RouteAutomatedSet { - RouteAutomatedSet { - lanes: self.automated, - rows: self.route_rows(), - } - } -} - -/// What a plan automates of one following route (#1654 D2): flags bits 4 and 5 (the source lanes -/// whose mute has a stored curve) and bits 6 to 11 (the route rows 1 to 6 with a stored curve, in -/// [`RouteFollowFlags::route_rows`]'s order). Fixed by the plan; render takes it from its own -/// cursors, never from a record. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct RouteAutomatedSet { - /// Per source lane `[L, R]`: whether the plan automates the lane's mute. - pub lanes: [bool; 2], - /// Per route row 1 to 6: whether the plan automates it. - pub rows: [bool; GRAPH_ROUTE_ROWS], } /// One live change of a following route (#1652 D1): its open transform, its flags and the ramp in @@ -1307,9 +1248,7 @@ impl FollowRouteControlRecord { length, }) } - /// The open transform: the route's linear gain and its 2x2, ungated. When the flags' - /// `automated_gain` is set, `gain` is the hand offset in dB instead (#1654), and a matrix entry - /// whose row the plan automates is the static fallback, which render does not hear. + /// The open transform: the route's linear gain and its 2x2, ungated. #[must_use] pub const fn transform(&self) -> RouteTransform { self.transform @@ -1347,7 +1286,7 @@ impl FollowRouteControlRecord { } // REALTIME_POLICY_BEGIN - /// The record a cell's words hold: `None` for words no record wrote (a flags bit above bit 11, + /// 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 { @@ -1415,40 +1354,24 @@ impl FollowRouteControlLane { } // REALTIME_POLICY_END -/// The route rows 1 to 6 a following route can automate (#1654 D1): `gain_db`, `mute`, `ll`, -/// `lr`, `rl`, `rr`, in that order. Graph spells no row ID; the order is the one of -/// [`RouteFollowFlags::route_rows`] and of [`GraphFollowRouteSource`]'s route cells. -pub const GRAPH_ROUTE_ROWS: usize = 6; - -/// Which jump length one cursor of a following route takes (#1654 D1, batch 19 spec check): the -/// mute rule for a source lane's mute and for the route's own mute, `VcaMute` for a reaching -/// VCA's mute (#1655 D4), `SendGain` for the route's gain and `SendMatrix` for a matrix entry. +/// Which jump length one cursor of a following route takes (#1652b, #1655 D4): the mute rule for +/// a source lane's own mute, `VcaMute` for a reaching VCA's mute. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum GraphRouteCursorRow { /// A source lane's stored mute (#1652). SourceMute, /// The stored mute of an automated VCA that reaches the source lane (#1655 D4). VcaMute, - /// The route's own `gain_db` (row 1). - Gain, - /// The route's own `mute` (row 2). - Mute, - /// One of the route's matrix entries (rows 3 to 6). - Matrix, } -/// The jump lengths, in samples, of a following route's cursor rows for one block (#1654 D1), -/// resolved from the block's three session lengths by [`GraphRouteJumpRule`]. +/// The jump lengths, in samples, of a following route's cursor rows for one block, resolved from +/// the block's three session lengths by [`GraphRouteJumpRule`]. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct GraphRouteJumpLengths { - /// The mute row's length: a source lane's mute and the route's own mute. + /// The mute row's length: a source lane's own mute. pub mute: u32, /// The `VcaMute` row's length: a reaching VCA's mute curve (#1655 D4). pub vca_mute: u32, - /// The `SendGain` row's length. - pub gain: u32, - /// The `SendMatrix` row's length. - pub matrix: u32, } impl GraphRouteJumpLengths { @@ -1457,34 +1380,24 @@ impl GraphRouteJumpLengths { #[must_use] pub const fn for_row(self, row: GraphRouteCursorRow) -> u32 { match row { - GraphRouteCursorRow::SourceMute | GraphRouteCursorRow::Mute => self.mute, + GraphRouteCursorRow::SourceMute => self.mute, GraphRouteCursorRow::VcaMute => self.vca_mute, - GraphRouteCursorRow::Gain => self.gain, - GraphRouteCursorRow::Matrix => self.matrix, } } // REALTIME_POLICY_END } /// The jump lengths of a following route's cursor rows, resolved from the block's three session -/// lengths (#1652b, #1654 D1, #1655 D4). Host-core hands in the row rules -/// (`controls::LiveRamps::from_lengths` and `for_row` of `LiveRampRow::Mute`, `VcaMute`, -/// `SendGain` and `SendMatrix`, the rules the strip's stages use), so graph never spells a row and -/// the strip ramp and the send ramp cannot differ in length. +/// lengths (#1652b, #1655 D4). Host-core hands in the row rules +/// (`controls::LiveRamps::from_lengths` and `for_row` of `LiveRampRow::Mute` and `VcaMute`, the +/// rules the strip's stages use), so graph never spells a row and the strip ramp and the send ramp +/// cannot differ in length. pub type GraphRouteJumpRule = fn(GraphJumpLengths) -> GraphRouteJumpLengths; -/// The route's dB law for an automated send gain (#1654, batch 10 amendment): the linear gain of -/// the curve value `curve_db` with the hand offset `offset_db`, that is -/// `db_to_gain_f32(clamp(f64(curve) + f64(offset), -144, 24) as f32)`. Graph does not link -/// `math`; host-core hands in `graph_compiler::route_gain_law`, the function the plan's own -/// `route_transform` calls for a static gain, so the two are one derivation. -pub type GraphRouteGainLaw = fn(curve_db: f32, offset_db: f32) -> f32; - -/// What one following route reads of its stored automation (#1652 D2, #1652b, #1654 D2, #1655 -/// D4): the plan's immutable automation program, the source lanes' mute cells in it, the cells of -/// the automated VCA mutes that reach each source lane, the route's own row cells in it, the -/// arrival `a(tap)` of the route's tap, the cursor rows' jump-length rule, the route's gain law -/// and the seeded flags word. +/// What one following route reads of its stored automation (#1652 D2, #1652b, #1655 D4): the +/// plan's immutable automation program, the source lanes' mute cells in it, the cells of the +/// automated VCA mutes that reach each source lane, the arrival `a(tap)` of the route's tap, the +/// cursor rows' jump-length rule 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 @@ -1498,77 +1411,51 @@ pub struct GraphFollowRouteSource { /// Per source lane, the mute cells of the automated VCAs that reach it, in ascending VCA ID /// (#1655 D4): the route ORs them into the lane's follow term at `a(tap)`. pub(crate) vca_mutes: [Box<[u32]>; 2], - pub(crate) route_cells: [Option; GRAPH_ROUTE_ROWS], pub(crate) arrival: u64, pub(crate) jumps: GraphRouteJumpRule, - pub(crate) gain_law: GraphRouteGainLaw, pub(crate) flags: RouteFollowFlags, - /// Per route row, whether the predecessor plan holds the same table for it, so a carry keeps - /// its cursor verbatim ([`Self::with_continued_rows`]). - pub(crate) continued: [bool; GRAPH_ROUTE_ROWS], /// Per source lane, whether the predecessor plan holds the same VCA mute tables for it, in /// the same order ([`Self::with_continued_vca_mutes`]). pub(crate) continued_vca: [bool; 2], - /// The seeded hand offset in dB of an automated `gain_db` (#1654, batch 10 amendment): the - /// cell's `gain` word before any record. `+0.0` when the flags do not automate the gain. - pub(crate) gain_offset: f32, } impl GraphFollowRouteSource { /// The source of a following route: `cells[lane]` is the source lane's mute cell in /// `program`, `None` for a lane whose own mute the plan does not automate; `vca_mutes[lane]` /// are the mute cells in `program` of the automated VCAs that reach the lane, in ascending VCA - /// ID (#1655 D4, empty when none does); `route_cells[row]` is the route's own row cell in - /// `program` (order [`GRAPH_ROUTE_ROWS`]), `None` for a row the plan does not automate; - /// `arrival` is `a(tap)`; `jumps` resolves the cursor rows' jump lengths; `gain_law` is the - /// route's dB law; `flags` is the seeded flags word. + /// ID (#1655 D4, empty when none does); `arrival` is `a(tap)`; `jumps` resolves the cursor + /// rows' jump lengths; `flags` is the seeded flags word. /// - /// `None` when no source lane and no route row is automated (the route keeps the six-word - /// cell), when a cell is not in `program`, or when `flags.automated` disagrees with the lanes - /// that have a curve (own cell or VCA mute cells) or `flags.route_rows()` with `route_cells` - /// (the plan fixes which lanes and rows are automated, D1). + /// `None` when no source lane is automated (the route keeps the six-word cell), when a cell is + /// not in `program`, or when `flags.automated` disagrees with the lanes that have a curve (own + /// cell or VCA mute cells; the plan fixes which lanes are automated, D1). #[must_use] - #[allow( - clippy::too_many_arguments, - reason = "the plan-fixed parts of one source, each its own type" - )] pub fn new( program: std::sync::Arc, cells: [Option; 2], vca_mutes: [Vec; 2], - route_cells: [Option; GRAPH_ROUTE_ROWS], arrival: u64, jumps: GraphRouteJumpRule, - gain_law: GraphRouteGainLaw, flags: RouteFollowFlags, ) -> Option { let automated = [0, 1].map(|lane| { cells.get(lane).copied().flatten().is_some() || vca_mutes.get(lane).is_some_and(|list| !list.is_empty()) }); - let rows = route_cells.map(|cell| cell.is_some()); let in_program = cells .iter() - .chain(&route_cells) .flatten() .chain(vca_mutes.iter().flatten()) .all(|&cell| program.cell(cell as usize).is_some()); - let any = automated.contains(&true) || rows.contains(&true); - (any && in_program && flags.automated == automated && flags.route_rows() == rows).then( - || Self { - program, - cells, - vca_mutes: vca_mutes.map(Vec::into_boxed_slice), - route_cells, - arrival, - jumps, - gain_law, - flags, - continued: [false; GRAPH_ROUTE_ROWS], - continued_vca: [false; 2], - gain_offset: 0.0, - }, - ) + (automated.contains(&true) && in_program && flags.automated == automated).then(|| Self { + program, + cells, + vca_mutes: vca_mutes.map(Vec::into_boxed_slice), + arrival, + jumps, + flags, + continued_vca: [false; 2], + }) } /// This source with `lanes[lane]` set for each source lane whose VCA mute tables the @@ -1582,37 +1469,6 @@ impl GraphFollowRouteSource { self } - /// This source with `rows[row]` set for each route row (order [`GRAPH_ROUTE_ROWS`]) whose - /// table the predecessor plan holds unchanged (#1654 D3, the Carry paragraph): at a plan swap - /// that row's cursor carries its event state and heard value verbatim, where every other - /// cursor is placed again at the successor's node time and jumps over its own row's length if - /// its curve moved. A row the plan does not automate ignores its entry. By default no row - /// continues. - #[must_use] - pub const fn with_continued_rows(mut self, rows: [bool; GRAPH_ROUTE_ROWS]) -> Self { - self.continued = rows; - self - } - - /// This source with the seeded hand offset `offset_db` of the route's automated `gain_db` - /// (#1654 R17-6): the value the cell's `gain` word holds before the first record. A source - /// from [`Self::new`] seeds `+0.0`, the entry's default offset. `None` when the flags do not - /// automate the gain or `offset_db` is not finite. - #[must_use] - pub fn with_gain_offset(mut self, offset_db: f32) -> Option { - if !self.flags.automated_gain || !offset_db.is_finite() { - return None; - } - self.gain_offset = offset_db; - Some(self) - } - - /// The seeded hand offset in dB of an automated `gain_db` ([`Self::with_gain_offset`]). - #[must_use] - pub const fn gain_offset(&self) -> f32 { - self.gain_offset - } - /// `a(tap)`, the arrival the route's events are timed at. #[must_use] pub const fn arrival(&self) -> u64 { @@ -1634,8 +1490,8 @@ pub struct GraphFollowRouteControlBinding { 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, the route's own row cells, `a(tap)`, the jump rule, the - /// gain law and the seeded flags. + /// The source strip's mute cells, the reaching VCA mute cells, `a(tap)`, the jump rule and + /// the seeded flags. pub source: GraphFollowRouteSource, /// The route op's cursor tables, allocated at attach so a failed allocation is a typed /// refusal ([`GraphRouteControlError::CursorTable`]); bind moves them into the op. @@ -1658,10 +1514,10 @@ 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 and route rows the plan automates (the bound source's flags bits 4 to 11, #1652 - /// D1, #1654 D2): render takes them from its own cursors, so a record whose set differs is - /// refused by the commit before any write (#1653 D5). - automated: RouteAutomatedSet, + /// 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], /// The bytes of the route op's cursor tables bind allocates (#1654 T3, R19-3: per column with /// a curve, its merged walk's table and its probe twin's), and the largest one. cursor_bytes: u64, @@ -1669,10 +1525,10 @@ pub struct GraphFollowRouteControlProducer { } impl GraphFollowRouteControlProducer { - /// The source lanes and route rows the plan automates, as bound (#1653 D5, #1654 D2): the set - /// every record written to this cell must carry ([`RouteFollowFlags::automated_set`]). + /// 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) -> RouteAutomatedSet { + pub const fn automated(&self) -> [bool; 2] { self.automated } @@ -1732,7 +1588,7 @@ pub(crate) fn follow_route_control_pair( cell: engine::realtime::LatestCell::new(), dirty: engine::realtime::DirtyWord::new(), }); - let automated = source.flags().automated_set(); + let automated = source.flags().automated; let (cursor_bytes, cursor_largest) = runtime::follow_route_cursor_bytes(&source); Ok(( GraphFollowRouteControlBinding { @@ -5196,10 +5052,10 @@ pub struct GraphLiveRoute { /// Whether the route is a following route (#1652 D1): a successor's move from this row /// carries [`GraphRouteMove::predecessor_follows`]. pub following: bool, - /// A following route's automated set, the curves its cursors hear (#1654 D2); the default - /// (nothing) for a six-word route. A successor's join compares it with its own to find a - /// carried route that loses a row or a source mute curve (B19-R7). - pub automated: RouteAutomatedSet, + /// A following route's automated source lanes (its flags' `automated`, the follow curves its + /// cursors hear); `[false; 2]` for a six-word route. A successor's join compares it with its + /// own to find a carried route that loses a source mute curve (B19-R7). + pub automated: [bool; 2], } impl GraphLiveRoute { @@ -10711,14 +10567,6 @@ mod follow_route_tests { follows_mute: word & 2 != 0, terms: [word & 4 != 0, word & 8 != 0], automated: [word & 16 != 0, word & 32 != 0], - automated_gain: word & 64 != 0, - automated_mute: word & 128 != 0, - automated_matrix: [ - word & 256 != 0, - word & 512 != 0, - word & 1024 != 0, - word & 2048 != 0, - ], } } @@ -10736,22 +10584,14 @@ mod follow_route_tests { StableGraphId::parse(value).expect("a route ID") } - /// The cursor rows' jump lengths of the tests: the session's `mute_ms`, `fader_ms` and - /// `pan_ms` lengths. + /// The cursor rows' jump lengths of the tests: the session's `mute_ms` length for both rows. pub(crate) fn route_jumps(lengths: GraphJumpLengths) -> GraphRouteJumpLengths { GraphRouteJumpLengths { mute: lengths.mute_samples, vca_mute: lengths.mute_samples, - gain: lengths.fader_samples, - matrix: lengths.pan_samples, } } - /// The gain law of the tests: the plain sum, which no test of this tranche renders. - pub(crate) fn sum_law(curve_db: f32, offset_db: f32) -> f32 { - curve_db + offset_db - } - /// 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( @@ -10788,10 +10628,8 @@ mod follow_route_tests { program, cells, [Vec::new(), Vec::new()], - [None; GRAPH_ROUTE_ROWS], arrival, route_jumps, - sum_law, flags, ) .expect("a valid source") @@ -10813,13 +10651,11 @@ mod follow_route_tests { .collect() } - /// A source refuses no automated lane or row, a cell outside its program and `automated` - /// flags that disagree with its cells; it accepts a route whose only automation is its own - /// rows (#1654 D2), and refuses route-row flags that disagree with its route cells. + /// A source refuses no automated lane, a cell outside its program and `automated` flags that + /// disagree with its cells (own or reaching VCA mute cells, #1655 D4). /// - /// Test value: red if a route with no automation takes the seven-word render state, if a send - /// with only route-row automation is refused it, or if render could look up a cell its program - /// does not hold. + /// Test value: red if a route with no automation 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); @@ -10827,57 +10663,45 @@ mod follow_route_tests { assert_eq!(source.arrival(), 7); let program = std::sync::Arc::clone(&source.program); let flags = source.flags(); - let new_with = |cells, vca_mutes, route_cells, flags| { + let new_with = |cells, vca_mutes, flags| { GraphFollowRouteSource::new( std::sync::Arc::clone(&program), cells, vca_mutes, - route_cells, 0, route_jumps, - sum_law, flags, ) .is_some() }; - let new = |cells, route_cells, flags| { - new_with(cells, [Vec::new(), Vec::new()], route_cells, flags) - }; - let no_rows = [None; GRAPH_ROUTE_ROWS]; - assert!(new([Some(0), None], no_rows, flags)); + let new = |cells, flags| new_with(cells, [Vec::new(), Vec::new()], flags); + assert!(new([Some(0), None], flags)); // #1655 D4: a lane is automated by its own cell or by a reaching VCA mute's cells. let both = RouteFollowFlags { automated: [true, true], ..flags }; assert!( - new_with([Some(0), None], [Vec::new(), vec![0]], no_rows, both), + new_with([Some(0), None], [Vec::new(), vec![0]], both), "a lane only a VCA mute reaches" ); assert!( - new_with([None, None], [vec![0, 0], vec![0]], no_rows, both), + new_with([None, None], [vec![0, 0], vec![0]], both), "VCA mute cells alone" ); assert!( - !new_with([Some(0), None], [Vec::new(), vec![1]], no_rows, both), + !new_with([Some(0), None], [Vec::new(), vec![1]], both), "a VCA mute cell outside the program" ); assert!( - !new_with([Some(0), None], [vec![0], Vec::new()], no_rows, both), + !new_with([Some(0), None], [vec![0], Vec::new()], both), "VCA flags that disagree" ); - assert!( - !new([None, None], no_rows, RouteFollowFlags::default()), - "no lane" - ); - assert!( - !new([Some(1), None], no_rows, flags), - "a cell outside the program" - ); + assert!(!new([None, None], RouteFollowFlags::default()), "no lane"); + assert!(!new([Some(1), None], flags), "a cell outside the program"); assert!( !new( [Some(0), None], - no_rows, RouteFollowFlags { automated: [true, true], ..flags @@ -10885,104 +10709,10 @@ mod follow_route_tests { ), "flags that disagree" ); - - // Route-row automation only: the route's gain and its `rl` entry over the one cell. - let gain_and_rl = [Some(0), None, None, None, Some(0), None]; - let rows_only = RouteFollowFlags { - automated_gain: true, - automated_matrix: [false, false, true, false], - ..RouteFollowFlags::default() - }; - assert!(new([None, None], gain_and_rl, rows_only), "route rows only"); - assert!( - !new( - [None, None], - [Some(0), None, None, None, Some(1), None], - rows_only - ), - "a route cell outside the program" - ); - for disagreeing in [ - RouteFollowFlags { - automated_mute: true, - ..rows_only - }, - RouteFollowFlags { - automated_matrix: [false, false, false, true], - ..rows_only - }, - RouteFollowFlags { - automated_gain: false, - ..rows_only - }, - ] { - assert!( - !new([None, None], gain_and_rl, disagreeing), - "route-row flags that disagree: {disagreeing:?}" - ); - } - } - - /// #1654 T4: the automated set a commit compares before any write is exactly flags bits 4-11 - /// (each bit alone moves it, no other bit does), and a source seeds a hand offset only for an - /// automated gain and only a finite one. - /// - /// Test value: red if `automated_set` drops a lane or row bit (the commit's pre-write check - /// then admits a record whose set render does not hold) or reads a value bit, or if a seed - /// offset reaches a route whose gain is not automated. - #[test] - fn the_automated_set_is_bits_4_to_11_and_only_an_automated_gain_takes_a_seed_offset() { - for bit in 0..12 { - let flags = RouteFollowFlags::from_word(1 << bit).expect("a defined bit"); - let set = flags.automated_set(); - let mut expected = RouteAutomatedSet::default(); - match bit { - 4 | 5 => expected.lanes[bit - 4] = true, - 6..=11 => expected.rows[bit - 6] = true, - _ => {} - } - assert_eq!(set, expected, "bit {bit}"); - } - - let curve = steps(&[0, 128], &[0.0, 1.0], 4_096); - let source = mute_source([Some(&curve), None], 7); - let program = std::sync::Arc::clone(&source.program); - let rows = |gain: bool| { - let flags = RouteFollowFlags { - automated_gain: gain, - automated_matrix: [true, false, false, false], - ..RouteFollowFlags::default() - }; - GraphFollowRouteSource::new( - std::sync::Arc::clone(&program), - [None, None], - [Vec::new(), Vec::new()], - [gain.then_some(0), None, Some(0), None, None, None], - 0, - route_jumps, - sum_law, - flags, - ) - .expect("a route-row source") - }; - assert_eq!(rows(true).gain_offset().to_bits(), 0.0_f32.to_bits()); - let seeded = rows(true) - .with_gain_offset(-3.5) - .expect("an automated gain"); - assert_eq!(seeded.gain_offset().to_bits(), (-3.5_f32).to_bits()); - assert!( - rows(true).with_gain_offset(f32::NAN).is_none(), - "not finite" - ); - assert!( - rows(false).with_gain_offset(-3.5).is_none(), - "a gain the plan does not automate" - ); } - /// The cursor rows' jump lengths: the source mute and the route mute take the mute length, - /// a VCA mute the `VcaMute` length, the gain the `SendGain` length and a matrix entry the - /// `SendMatrix` length. + /// The cursor rows' jump lengths: the source mute takes the mute length and a VCA mute the + /// `VcaMute` length. /// /// Test value: red if a route cursor would jump over another row's length. #[test] @@ -10990,33 +10720,24 @@ mod follow_route_tests { let lengths = GraphRouteJumpLengths { mute: 3, vca_mute: 4, - gain: 5, - matrix: 7, }; assert_eq!(lengths.for_row(GraphRouteCursorRow::SourceMute), 3); assert_eq!(lengths.for_row(GraphRouteCursorRow::VcaMute), 4); - assert_eq!(lengths.for_row(GraphRouteCursorRow::Mute), 3); - assert_eq!(lengths.for_row(GraphRouteCursorRow::Gain), 5); - assert_eq!(lengths.for_row(GraphRouteCursorRow::Matrix), 7); } - /// The layout: bit 0 mute, 1 follows_mute, 2-3 terms L/R, 4-5 automated L/R, 6 - /// automated_gain, 7 automated_mute, 8-11 automated_matrix ll/lr/rl/rr (#1654 T2). Every one of - /// the 4096 defined words decodes to its fields and encodes back to itself, and its route rows - /// read bits 6 to 11 in row order; a word with any bit from 12 up set is refused. + /// 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_twelve_bits_and_refuses_every_other_bit() { - for word in 0..4_096_u32 { + 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); - let rows: [bool; GRAPH_ROUTE_ROWS] = - core::array::from_fn(|row| (word >> (6 + row)) & 1 != 0); - assert_eq!(flags.route_rows(), rows, "word {word:#b}"); - for bit in 12..32 { + for bit in 6..32 { assert_eq!( RouteFollowFlags::from_word(word | 1 << bit), None, @@ -11080,20 +10801,6 @@ mod follow_route_tests { assert_eq!(record.flags(), flags); assert_eq!(record.length(), 480); - // #1654 T2: the route-row bits round trip, and an automated gain's offset word (dB) fits. - let rows = flags_of(0b1010_1110_0110); - let offset = RouteTransform { - gain: -6.0, - ..transform() - }; - let record = FollowRouteControlRecord::new(offset, rows, 64).expect("a record"); - assert_eq!(record.to_words()[0], (-6.0_f32).to_bits()); - assert_eq!(record.to_words()[5], 0b1010_1110_0110); - assert_eq!( - FollowRouteControlRecord::from_words(record.to_words()), - Some(record) - ); - let bound = FollowRouteControlRecord::new(transform(), flags, ROUTE_RAMP_LENGTH_MAXIMUM) .expect("the bound is in bounds"); assert_eq!( @@ -11115,7 +10822,7 @@ mod follow_route_tests { long[6] = ROUTE_RAMP_LENGTH_MAXIMUM + 1; assert_eq!(FollowRouteControlRecord::from_words(long), None); let mut undefined = words; - undefined[5] |= 1 << 12; + undefined[5] |= 1 << 6; assert_eq!(FollowRouteControlRecord::from_words(undefined), None); } diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index 721bab084..9ec45d6b5 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -1273,14 +1273,13 @@ impl LiveRoute { // REALTIME_POLICY_END -/// One following route's render state (#1652 D1, D2; #1654 T3): a route into a submix whose -/// source strip has an automated mute lane, or whose own `gain_db`, `mute` or matrix rows are -/// automated. Its own type, boxed beside [`LiveRoute`], so a six-word route keeps its bytes +/// One following route's render state (#1652 D1, D2; #1654 T3; #1655 D4): a route into a submix +/// whose source lane has an automated mute (its own, or a reaching VCA's). Its own type, boxed beside [`LiveRoute`], so a six-word route keeps its bytes /// ([`live_route_owner_bytes`]), its code path and its bits. /// /// `transform` and `flags` are the last applied cell words (or the seeded ones): render composes -/// each source column's target itself ([`column_target`]: the route's gain law, its matrix and -/// its gate, [`gated_route_coefficients`]) at the block entry after its cell changes and at every +/// each source column's target itself ([`column_target`]: its gate, +/// [`gated_route_coefficients`]) at the block entry after its cell changes and at every /// event of the curves it hears, which it walks with one merged walk per column /// ([`automation::Composed`], K1) in the node time of its tap, `τ_tap(r) = timeline(r + ΣP - /// a(tap))`. Only a column whose target bits change retargets: a new [`IndexedRamp`] for that @@ -1295,12 +1294,10 @@ pub(crate) struct LiveFollowRoute { delayed: bool, /// The route's index in the plan's [`RouteActivity`]. route: u32, - /// The open transform the last applied record (or preparation) gave. When the plan automates - /// the route's gain, `gain` is the hand offset in dB; an automated matrix entry's word is the - /// static fallback, which render does not hear. + /// The open transform the last applied record (or preparation) gave. transform: crate::RouteTransform, /// The flags the last applied record (or preparation) gave; render composes with the plan's - /// automated set (the cursors it holds, [`ColumnRoles`]), never the record's. + /// automated lanes (the cursors it holds, [`ColumnRoles`]), never the record's. flags: crate::RouteFollowFlags, control: Box, /// The merged walks over the curves the route hears. @@ -1405,14 +1402,6 @@ impl ColumnRamps { } } - /// Column `column`'s remaining ramp in samples (`0` when settled). - fn remaining(&self, column: usize) -> u32 { - match (self.ramps.get(column), self.positions.get(column)) { - (Some(ramp), Some(&position)) => ramp.length.saturating_sub(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]) { @@ -1455,27 +1444,17 @@ impl ColumnRamps { } // REALTIME_POLICY_END -/// The roles of the curves one source column hears (#1654 T3): its source lane's stored mute, -/// the route's own mute, the route's gain and the column's two matrix entries, in that order. The -/// mute curves of the automated VCAs that reach the source lane (#1655 D4) follow them in the -/// column's table, any number of them ([`ColumnRoles::vca`]). -const ROLES: usize = 5; -const SOURCE_MUTE: usize = 0; -const ROUTE_MUTE: usize = 1; -const GAIN: usize = 2; -/// The column's matrix entries, in the order of its [`COLUMNS`] indices. -const MATRIX: [usize; 2] = [3, 4]; - /// One source column's composed target: its two coefficients (its [`COLUMNS`] entries) and /// whether its gate silences it. type ColumnTarget = ([f32; 2], bool); -/// Which cursor of a column's table carries each role, by role (`None` for a role the plan does -/// not automate), and the cursors of the reaching VCA mute curves (#1655 D4), which follow every -/// role's cursor in the table, in ascending VCA ID. +/// Which cursors of a column's table carry its follow curves (#1652 D2, #1655 D4): the source +/// lane's own stored mute (`None` when the plan does not automate it) first, then the mute curves +/// of the automated VCAs that reach the source lane, any number of them, in ascending VCA ID. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] struct ColumnRoles { - fixed: [Option; ROLES], + /// The source lane's own mute cursor's index. + own: Option, /// The first VCA mute cursor's index. vca_start: usize, /// How many VCA mute cursors the column holds. @@ -1483,11 +1462,6 @@ struct ColumnRoles { } impl ColumnRoles { - /// The cursor index of `role`. - fn of(self, role: usize) -> Option { - self.fixed.get(role).copied().flatten() - } - /// The cursor indices of the column's VCA mute curves (#1655 D4). const fn vca(self) -> core::ops::Range { self.vca_start..self.vca_start.saturating_add(self.vca_count) @@ -1495,96 +1469,40 @@ impl ColumnRoles { /// Whether the column hears a curve of its source lane's follow term: the lane's own mute or /// a reaching VCA mute. - fn has_follow(self) -> bool { - self.of(SOURCE_MUTE).is_some() || self.vca_count > 0 + const fn has_follow(self) -> bool { + self.own.is_some() || self.vca_count > 0 } /// The curves' share of the column's follow term under the values `value(cursor)` (#1652 D2, /// #1655 D4): the source lane's own mute curve OR each reaching VCA mute curve. `false` with /// no such curve. fn follow_curve(self, value: impl Fn(usize) -> f32) -> bool { - self.of(SOURCE_MUTE) - .is_some_and(|index| value(index) != 0.0) + self.own.is_some_and(|index| value(index) != 0.0) || self.vca().any(|index| value(index) != 0.0) } - - /// The jump-length row of `role`: the mute rule for both mutes, `SendGain` for the gain, - /// `SendMatrix` for a matrix entry. - const fn row(role: usize) -> crate::GraphRouteCursorRow { - match role { - SOURCE_MUTE => crate::GraphRouteCursorRow::SourceMute, - ROUTE_MUTE => crate::GraphRouteCursorRow::Mute, - GAIN => crate::GraphRouteCursorRow::Gain, - _ => crate::GraphRouteCursorRow::Matrix, - } - } - - /// The route row (`GRAPH_ROUTE_ROWS` order: `gain_db`, `mute`, `ll`, `lr`, `rl`, `rr`) of - /// `role` in `column`; `None` for the source mute, which is not a route row. - fn route_row(column: usize, role: usize) -> Option { - let indices = COLUMNS.get(column)?; - match role { - ROUTE_MUTE => Some(1), - GAIN => Some(0), - _ => { - let entry = MATRIX.iter().position(|&matrix| matrix == role)?; - indices.get(entry).map(|&index| index + 2) - } - } - } } -/// How a carried cursor is placed at its route's first block (#1654 D3, T3). -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum Placement { - /// The predecessor held the same table: its state and heard value carry verbatim. - Continued, - /// The predecessor held another table of the row: its state carries and is repositioned at - /// the successor's node time, so its target is still the one the column heard. - Reposition, - /// The predecessor did not automate the row: a state with no target, placed at the - /// successor's node time. - New, -} - -/// The cells of `column`'s cursor table under `source` and their roles: the roles' cursors, then -/// the source lane's VCA mute cursors (#1655 D4). Control side: it allocates the list. +/// The cells of `column`'s cursor table under `source` and their roles: the source lane's own +/// mute cursor, then its VCA mute cursors (#1655 D4), every one a gate. Control side: it +/// allocates the list. fn column_cells( source: &crate::GraphFollowRouteSource, column: usize, ) -> (Vec<(u32, automation::CursorKind)>, ColumnRoles) { - use automation::CursorKind::{Gate, Value}; - let indices = COLUMNS.get(column).copied().unwrap_or([0, 2]); - let route_cell = |row: usize| source.route_cells.get(row).copied().flatten(); - let wanted = [ - ( - SOURCE_MUTE, - source.cells.get(column).copied().flatten(), - Gate, - ), - (ROUTE_MUTE, route_cell(1), Gate), - (GAIN, route_cell(0), Value), - (MATRIX[0], route_cell(2 + indices[0]), Value), - (MATRIX[1], route_cell(2 + indices[1]), Value), - ]; + use automation::CursorKind::Gate; + let own = source.cells.get(column).copied().flatten(); let vca: &[u32] = source.vca_mutes.get(column).map_or(&[], |list| list); - let mut cells = Vec::with_capacity(ROLES + vca.len()); - let mut roles = [None; ROLES]; - for (role, cell, kind) in wanted { - let Some(cell) = cell else { - continue; - }; - if let Some(role_slot) = roles.get_mut(role) { - *role_slot = Some(cells.len()); - cells.push((cell, kind)); - } - } + let mut cells = Vec::with_capacity(usize::from(own.is_some()) + vca.len()); + let own = own.map(|cell| { + cells.push((cell, Gate)); + 0 + }); let vca_start = cells.len(); cells.extend(vca.iter().map(|&cell| (cell, Gate))); ( cells, ColumnRoles { - fixed: roles, + own, vca_start, vca_count: vca.len(), }, @@ -1618,23 +1536,21 @@ pub(crate) fn follow_route_cursor_bytes(source: &crate::GraphFollowRouteSource) } /// One source column's merged walk (#1654 T3, K1): its cursor table over the curves it hears, a -/// twin over the same cells for the block entry's activity probe, its roles and its cursors' -/// placement for a carried first block. +/// twin over the same cells for the block entry's activity probe, its roles and whether its VCA +/// mute cursors continue at a carried first block. struct RouteColumn { walk: automation::Composed, probe: automation::Composed, roles: ColumnRoles, - placement: [Placement; ROLES], - /// The placement of every VCA mute cursor (#1655 D4): `Continued` when the lane's VCA mute - /// tables continue, else placed again at the first block's node time. - vca_placement: Placement, + /// Whether every VCA mute cursor (#1655 D4) carried its predecessor's state verbatim (the + /// lane's VCA mute tables continue); else they are placed again at the first block's node + /// time, as the own mute cursor always is. + vca_continued: bool, } impl RouteColumn { /// `column`'s walk under `source`, or `None` when the column hears no curve. Control side: - /// it allocates the two tables ([`follow_route_cursor_bytes`]). Each table holds its grid - /// inside every column retarget (K1 `with_held_grid`, U2 fold 1 MAJOR-4: a value step keeps - /// its jump length while another curve rides). + /// it allocates the two tables ([`follow_route_cursor_bytes`]). /// /// # Errors /// @@ -1648,16 +1564,13 @@ impl RouteColumn { if cells.is_empty() { return Ok(None); } - let table = || { - automation::Composed::new(&source.program, &cells, automation::Completion::End) - .map(automation::Composed::with_held_grid) - }; + let table = + || automation::Composed::new(&source.program, &cells, automation::Completion::End); Ok(Some(Self { walk: table()?, probe: table()?, roles, - placement: [Placement::Continued; ROLES], - vca_placement: Placement::Continued, + vca_continued: true, })) } } @@ -1684,8 +1597,7 @@ impl FollowColumns { /// A following route's merged walks over the curves it hears (#1652 D2, #1654 T3): the plan's /// immutable program, one [`RouteColumn`] per source column that hears a curve, whether each -/// column's target silences it, `a(tap)`, the cursor rows' jump-length rule, the route's gain law -/// and which route rows continue the predecessor's table at a carry. +/// column's target silences it, `a(tap)` and the cursor rows' jump-length rule. struct FollowCursors { program: std::sync::Arc, columns: [Option; 2], @@ -1693,16 +1605,13 @@ struct FollowCursors { /// `a(tap)`. arrival: u64, jumps: crate::GraphRouteJumpRule, - gain_law: crate::GraphRouteGainLaw, - continued: [bool; crate::GRAPH_ROUTE_ROWS], /// Per column, whether the predecessor's VCA mute tables of the source lane continue /// ([`crate::GraphFollowRouteSource::with_continued_vca_mutes`], #1655 D6). continued_vca: [bool; 2], - /// Per column, the roles the predecessor's column heard and this plan's does not (a removed - /// row or source mute curve), set by a carry and read by its first block (U2 fold 1, MAJOR-3). - /// The source mute's slot reads the follow curves as one: the predecessor heard its source - /// mute and this column hears no follow curve (own or VCA mute, #1655 D4). - dropped: [[bool; ROLES]; 2], + /// Per column, the predecessor's column heard its source lane's own mute curve and this + /// column hears no follow curve (own or VCA mute, #1655 D4): a removed curve, set by a carry + /// and read by its first block (U2 fold 1, MAJOR-3). + dropped: [bool; 2], /// Per column, the predecessor heard VCA mute curves and this column hears no follow curve /// (#1655 D4): a removed curve that lands at once over the `VcaMute` length at the carry /// (B19-R7). @@ -1744,20 +1653,16 @@ impl FollowBlock { lengths: crate::GraphRouteJumpLengths { mute: 0, vca_mute: 0, - gain: 0, - matrix: 0, }, start: None, }; } -/// What a column's composition reads besides its heard values: the open transform, the flags and -/// the gain law. +/// What a column's composition reads besides its heard values: the open transform and the flags. #[derive(Clone, Copy)] struct Composition<'a> { transform: &'a crate::RouteTransform, flags: crate::RouteFollowFlags, - law: crate::GraphRouteGainLaw, } impl LiveFollowRoute { @@ -1766,32 +1671,22 @@ impl LiveFollowRoute { /// seeded with their curves' first `start_value`, and each column's bound target composes /// them with the seeded 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 target mixes. - /// Control side: it allocates the cursor tables. - /// - /// When the plan automates the gain (flags bit 6), the cell's `gain` word is the hand offset - /// in dB, not a linear gain: the bound word is the source's seeded offset - /// ([`crate::GraphFollowRouteSource::gain_offset`]), and `transform.gain` (the compiler's - /// static linear gain, a fallback render never hears) is replaced by it (#1654 T4 note). - /// `columns` are the source's walks ([`FollowColumns::bind`]). + /// Control side: it allocates the cursor tables. `columns` are the source's walks + /// ([`FollowColumns::bind`]). pub(crate) fn new( - mut transform: crate::RouteTransform, + transform: crate::RouteTransform, control: Box, source: &crate::GraphFollowRouteSource, columns: FollowColumns, ) -> Self { - if source.flags.automated_gain { - transform.gain = source.gain_offset; - } let mut follow = FollowCursors { program: std::sync::Arc::clone(&source.program), columns: columns.0, silent: [false; 2], arrival: source.arrival, jumps: source.jumps, - gain_law: source.gain_law, - continued: source.continued, continued_vca: source.continued_vca, - dropped: [[false; ROLES]; 2], + dropped: [false; 2], dropped_vca: [false; 2], follow_heard: [false; 2], start: FollowStart::Fresh, @@ -1799,7 +1694,6 @@ impl LiveFollowRoute { let composition = Composition { transform: &transform, flags: source.flags, - law: source.gain_law, }; let targets = [0, 1].map(|column| follow.heard_target(column, composition)); for ((column, target), silent) in follow @@ -1858,54 +1752,21 @@ fn same_entries(a: [f32; 2], b: [f32; 2]) -> bool { a.map(f32::to_bits) == b.map(f32::to_bits) } -/// The open transform's words, for a value-change test. -const fn transform_bits(transform: &crate::RouteTransform) -> [u32; 5] { - [ - transform.gain.to_bits(), - transform.ll.to_bits(), - transform.lr.to_bits(), - transform.rl.to_bits(), - transform.rr.to_bits(), - ] -} - -/// Source column `column`'s target (#1652 D2, #1654 D2, D5): the open transform with the plan's -/// automated values from the curves the column hears (`value(cursor)`): the gain through the -/// route's gain law with the record's hand offset (`law(curve, offset)`), each automated matrix -/// entry of the column from its curve; gated by `mute = static mute || route mute curve` (D5, -/// an OR) and the column's follow term `follows_mute && (source curve || VCA mute curves || -/// terms)` (#1655 D4: each reaching automated VCA mute in the tap's node time); -/// [`gated_route_coefficients`] of that, the column's entries. The one composition of the seed, -/// the cell's block entry, a record's target and every curve event, so their bits cannot differ. -/// A column's entries do not depend on the other column's gate, so the two columns compose apart. +/// Source column `column`'s target (#1652 D2): the open transform gated by the route's own mute +/// and the column's follow term `follows_mute && (source curve || VCA mute curves || terms)` +/// (#1655 D4: each reaching automated VCA mute in the tap's node time), under the curve values +/// `value(cursor)`; [`gated_route_coefficients`] of that, the column's entries. The one composition +/// of the seed, the cell's block entry, a record's target and every curve event, so their bits +/// cannot differ. A column's entries do not depend on the other column's gate, so the two columns +/// compose apart. fn column_target( column: usize, roles: ColumnRoles, composition: Composition<'_>, value: impl Fn(usize) -> f32, ) -> ColumnTarget { - let Composition { - transform, - flags, - law, - } = composition; - let heard = |role: usize| roles.of(role).map(&value); - let mut open = *transform; - if let Some(curve) = heard(GAIN) { - open.gain = law(curve, transform.gain); - } - let indices = COLUMNS.get(column).copied().unwrap_or([0, 2]); - for (&index, &role) in indices.iter().zip(&MATRIX) { - if let Some(curve) = heard(role) { - match index { - 0 => open.ll = curve, - 1 => open.lr = curve, - 2 => open.rl = curve, - _ => open.rr = curve, - } - } - } - let mute = flags.mute || heard(ROUTE_MUTE).is_some_and(|curve| curve != 0.0); + let Composition { transform, flags } = composition; + let mute = flags.mute; let zeroed = flags.follows_mute && (roles.follow_curve(&value) || flags.terms.get(column).copied().unwrap_or(false)); let mut follow_zeroed = [false; 2]; @@ -1913,7 +1774,7 @@ fn column_target( *slot = zeroed; } let all = gated_route_coefficients( - &open, + transform, crate::RouteGate { mute, follow_zeroed, @@ -1978,18 +1839,14 @@ impl FollowCursors { let lengths = crate::GraphRouteJumpLengths { mute: bound(rule.mute), vca_mute: bound(rule.vca_mute), - gain: bound(rule.gain), - matrix: bound(rule.matrix), }; for column in self.columns.iter_mut().flatten() { - for role in 0..ROLES { - if let Some(cursor) = column - .roles - .of(role) - .and_then(|index| column.walk.cursor_mut(index)) - { - cursor.set_jump(lengths.for_row(ColumnRoles::row(role))); - } + if let Some(cursor) = column + .roles + .own + .and_then(|index| column.walk.cursor_mut(index)) + { + cursor.set_jump(lengths.for_row(crate::GraphRouteCursorRow::SourceMute)); } // #1655 D4: each reaching VCA mute curve jumps over the `VcaMute` row's length. for index in column.roles.vca() { @@ -2097,75 +1954,19 @@ fn next_column_event( }) } -/// Call `each` on every value cursor of `walk` that a carried first block placed (it does not -/// continue its predecessor's table). -impl FollowCursors { - /// A carried route's first block, before its cell (#1654 T5): each value cursor of a row - /// the predecessor did not automate ([`Placement::New`]) hears its curve at node time `at` - /// and is placed there, so a join record composes with the value a fresh plan hears at `at`, - /// not with the cursor's bind seed (the curve at node time 0), which no plan heard. The - /// predecessor heard the row's static value, which the successor's composition cannot - /// express; a repositioned or continued cursor keeps the value the column heard. - /// [`LiveFollowRoute::place_carried`] leaves it placed, so a join record's column jump keeps - /// its deferral (R19-3). - fn place_new_values(&mut self, at: i64) { - let Self { - program, columns, .. - } = self; - for walk in columns.iter_mut().flatten() { - for role in 0..ROLES { - let new = walk.placement.get(role) == Some(&Placement::New); - let (Some(index), true) = (walk.roles.of(role), new) else { - continue; - }; - let Some(cursor) = walk.walk.cursor_mut(index) else { - continue; - }; - if cursor.kind() != automation::CursorKind::Value { - continue; - } - let Some(cell) = program.cell(cursor.cell() as usize) else { - continue; - }; - cursor.state_mut().reposition(&cell, at); - cursor.set_heard(automation::value_at(cell.segments(), at)); - } - } - } -} - -fn for_placed_values(walk: &mut RouteColumn, mut each: impl FnMut(&mut automation::ComposeCursor)) { - for role in 0..ROLES { - let placed = walk - .placement - .get(role) - .is_some_and(|&placement| placement != Placement::Continued); - if let (Some(index), true) = (walk.roles.of(role), placed) - && let Some(cursor) = walk.walk.cursor_mut(index) - && cursor.kind() == automation::CursorKind::Value - { - each(cursor); - } - } -} - impl LiveFollowRoute { /// 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). Returns how many values were /// applied: `0` or `1`. /// - /// With `at`, the node time of the block's first sample (a block entry), a record that - /// changes a value word (the hand offset of an automated gain, or a value the plan does not - /// automate, D6) takes each automated value from its curve at the record ramp's completion - /// (#1649 D3 on the route), and a gate-only record composes with the values heard (#1652); a - /// column the record moves is a column jump (R19-3): its value cursors hold their grid until - /// the ramp's end and land over their own length if their curve moved. With no `at` (a - /// predecessor's last read at a swap) the record composes with the values heard. + /// The record composes with the curve values heard (#1652). With `at`, the node time of the + /// block's first sample (a block entry), a column the record moves is a column jump over the + /// record's ramp ([`automation::Composed::retarget`]); with no `at` (a predecessor's last read + /// at a swap) the column's walk only takes the target. #[inline] fn apply_cell(&mut self, snapshot: LiveSnapshot, at: Option) -> usize { let mut applied = 0; if let Some(record) = self.control.read(snapshot) { - let values = transform_bits(&record.transform()) != transform_bits(&self.transform); self.transform = record.transform(); self.flags = record.flags(); let length = record.length(); @@ -2180,20 +1981,9 @@ impl LiveFollowRoute { let composition = Composition { transform, flags: *flags, - law: follow.gain_law, }; for column in 0..COLUMNS.len() { - let target = match (follow.columns.get(column).and_then(Option::as_ref), at) { - (Some(walk), Some(start)) if values => { - walk.walk - .compose_toward(&follow.program, start, length, |heard| { - column_target(column, walk.roles, composition, |index| { - heard.value(index) - }) - }) - } - _ => follow.heard_target(column, composition), - }; + let target = follow.heard_target(column, composition); let moves = !same_entries(column_entries(ramps.target(), column), target.0); ramps.retarget_column( column, @@ -2219,14 +2009,12 @@ impl LiveFollowRoute { } /// Take the ramp state of the predecessor's live route of the same ID, of either kind - /// (#1652 D3, #1654 D3): its column ramps (a six-word predecessor's one ramp in both - /// columns), their positions and its mute; from a following predecessor `from`, per column and - /// role, each cursor's state and heard value. A route row whose table continues - /// ([`crate::GraphFollowRouteSource::with_continued_rows`]) keeps them verbatim; every other - /// cursor is placed at this plan's first block (a cursor the predecessor did not hold starts - /// with no target), after its cell applies with the heard values, and the block entry then - /// compares. A source lane's VCA mute cursors (#1655 D4, D6) carry verbatim by position when - /// the lane's VCA mute tables continue + /// (#1652 D3): its column ramps (a six-word predecessor's one ramp in both columns), their + /// positions and its mute; from a following predecessor `from`, per column, the own mute + /// cursor's state and heard value, which is placed at this plan's first block (a cursor the + /// predecessor did not hold starts with no target), after its cell applies with the heard + /// values, and the block entry then compares. A source lane's VCA mute cursors (#1655 D4, D6) + /// carry verbatim by position when the lane's VCA mute tables continue /// ([`crate::GraphFollowRouteSource::with_continued_vca_mutes`]) and `a(tap)` did not move; /// else they start fresh and are placed at the first block. The follow curves heard (own and /// VCA mutes ORed) are kept for the first block's comparison. @@ -2234,7 +2022,6 @@ impl LiveFollowRoute { self.ramps = ramps; self.mute = mute; self.follow.silent = from.map_or([mute; 2], |from| from.follow.silent); - let continued = self.follow.continued; // #1655 D6: a lane's VCA mute cursors continue verbatim only when their tables do and // the tap's arrival did not move (their node time is the tap's). let same_arrival = from.is_some_and(|from| from.follow.arrival == self.follow.arrival); @@ -2250,19 +2037,11 @@ impl LiveFollowRoute { let previous = from .and_then(|from| from.follow.columns.get(column)) .and_then(Option::as_ref); - // The roles the predecessor's column heard that this column does not (MAJOR-3). The - // follow curves (own and VCA mutes) are one gate: dropped only when none is left. + // The follow curves the predecessor's column heard that this column does not + // (MAJOR-3). The follow curves (own and VCA mutes) are one gate: dropped only when + // none is left. let has_follow = walk.as_ref().is_some_and(|walk| walk.roles.has_follow()); - for (role, slot) in dropped.iter_mut().enumerate() { - let had = previous.is_some_and(|previous| previous.roles.of(role).is_some()); - let has = if role == SOURCE_MUTE { - has_follow - } else { - walk.as_ref() - .is_some_and(|walk| walk.roles.of(role).is_some()) - }; - *slot = had && !has; - } + *dropped = previous.is_some_and(|previous| previous.roles.own.is_some()) && !has_follow; let previous_vca = previous.map_or(0, |previous| previous.roles.vca_count); if let Some(slot) = dropped_vca.get_mut(column) { *slot = previous_vca > 0 && !has_follow; @@ -2299,74 +2078,46 @@ impl LiveFollowRoute { } } } - walk.vca_placement = if vca_continues { - Placement::Continued - } else { - Placement::Reposition + walk.vca_continued = vca_continues; + let Some(index) = walk.roles.own else { + continue; }; - for role in 0..ROLES { - let Some(index) = walk.roles.of(role) else { - continue; - }; - let carried = previous.and_then(|previous| { - let index = previous.roles.of(role)?; - previous.walk.cursors().get(index) - }); - let continues = ColumnRoles::route_row(column, role) - .and_then(|row| continued.get(row).copied()) - .unwrap_or(false); - let placement = match carried { - Some(_) if continues => Placement::Continued, - Some(_) => Placement::Reposition, - None => Placement::New, - }; - if let Some(cursor) = walk.walk.cursor_mut(index) { - match carried { - Some(carried) => { - *cursor.state_mut() = *carried.state(); - cursor.set_heard(carried.heard()); - } - None => { - *cursor.state_mut() = automation::CellState::new(); - if cursor.kind() == automation::CursorKind::Gate { - // A curve the predecessor did not hear gated nothing. - cursor.set_heard(0.0); - } - } + let carried = previous.and_then(|previous| { + let index = previous.roles.own?; + previous.walk.cursors().get(index) + }); + if let Some(cursor) = walk.walk.cursor_mut(index) { + match carried { + Some(carried) => { + *cursor.state_mut() = *carried.state(); + cursor.set_heard(carried.heard()); + } + None => { + // A curve the predecessor did not hear gated nothing. + *cursor.state_mut() = automation::CellState::new(); + cursor.set_heard(0.0); } - } - if let Some(slot) = walk.placement.get_mut(role) { - *slot = placement; } } } self.follow.start = FollowStart::Carried; } - /// A carried route's first block, after its cell (#1652 D3, #1654 D3, R19-5), at node time - /// `at`: each cursor that does not continue is placed there, a gate with the curve's value (no - /// `Set`), a value with its curve's value heard and its state repositioned (its target still - /// the one the column heard). Then each column composes and compares with the adopted ramp's - /// target: equal bits leave the ramp (the placed values become the cursors' targets, and with - /// a column ramp in flight they defer their jumps to its first free sample, R19-5); a gate - /// that moved retargets the column at once over the mute length, a column jump (R19-3); a - /// value that moved alone defers the jump of the column's placed value cursors to the column - /// ramp's first free sample, at once when it is settled (#1711 D1), where each one whose curve - /// moved jumps over its own row's length. A column that no longer hears a row or source mute - /// curve of its predecessor and composes otherwise (U2 fold 1, MAJOR-3: no cursor's curve - /// carries that move) lands at once over the removed row's own length (the mute length for a - /// mute curve, the longer one with a coincident moved value, R19-4), a column jump, also with - /// a column ramp in flight, which it cuts (B19-R7: the control plane marks such a rebuild - /// `TransitionFallback` while a live send move may still ramp; TBD54 lands it exactly). No - /// landing state survives the carry. A column with no curve compares its cell's target over - /// the mute length, at once (over the removed row's length when it lost one). - /// - /// #1655 D4: the follow curves (the source lane's own mute and every reaching VCA mute) are - /// compared as one gate, their OR against the one the predecessor heard; a move takes the - /// longest own length among the curves that close the column (for an open, among all of - /// them: the mute length for the own mute, `VcaMute` for a VCA mute, R19-4). A VCA mute - /// curve removed with no follow curve left lands at once over the `VcaMute` length, as a - /// removed row (B19-R7). + /// A carried route's first block, after its cell (#1652 D3), at node time `at`: the own mute + /// cursor and each VCA mute cursor that does not continue are placed there with the curve's + /// value (no `Set`). Then each column composes and compares with the adopted ramp's target: + /// equal bits leave the ramp; otherwise the column retargets at once (a column jump, R19-3) + /// over the longest own length among the follow curves that moved (#1655 D4: the follow + /// curves, the source lane's own mute and every reaching VCA mute, are compared as one gate, + /// their OR against the one the predecessor heard; a move takes the longest own length among + /// the curves that close the column, for an open among all of them: the mute length for the + /// own mute, `VcaMute` for a VCA mute, R19-4). A column that no longer hears a follow curve of + /// its predecessor (U2 fold 1, MAJOR-3) lands at once over the removed curve's own length (the + /// longer one with a coincident gate move, R19-4), also with a column ramp in flight, which + /// it cuts (B19-R7: the control plane marks such a rebuild `TransitionFallback` while a live + /// send move may still ramp; TBD54 lands it exactly). Any other move lands at once over the + /// mute length. No landing state survives the carry. A column with no curve compares its + /// cell's target the same way. fn place_carried(&mut self, at: i64) { let Self { ramps, @@ -2380,115 +2131,62 @@ impl LiveFollowRoute { let composition = Composition { transform, flags: *flags, - law: follow.gain_law, }; let lengths = block.lengths; let jump = lengths.mute; for column in 0..COLUMNS.len() { let adopted = column_entries(ramps.target(), column); - // The longest own length of the rows and curves the column no longer hears - // (MAJOR-3): a removed row lands over its own length, a removed mute curve over the - // mute length. + // The longest own length of the follow curves the column no longer hears (MAJOR-3): + // the mute length for its own mute, `VcaMute` for a VCA mute. + let dropped_own = follow.dropped.get(column).copied().unwrap_or(false); let dropped_vca = follow.dropped_vca.get(column).copied().unwrap_or(false); - let dropped = follow.dropped.get(column).map_or(0, |dropped| { - (0..ROLES) - .filter(|&role| dropped.get(role).copied().unwrap_or(false)) - .map(|role| lengths.for_row(ColumnRoles::row(role))) - .chain(dropped_vca.then_some(lengths.vca_mute)) - .max() - .unwrap_or(0) - }); - let has_dropped = dropped_vca - || follow - .dropped - .get(column) - .is_some_and(|dropped| dropped.iter().any(|&role| role)); - let remaining = ramps.remaining(column); - let free = at.saturating_add(i64::from(remaining)); + let has_dropped = dropped_own || dropped_vca; + let dropped = core::cmp::max( + if dropped_own { lengths.mute } else { 0 }, + if dropped_vca { lengths.vca_mute } else { 0 }, + ); + let landing = if has_dropped { dropped } else { jump }; let Some(walk) = follow.columns.get_mut(column).and_then(Option::as_mut) else { let target = column_target(column, ColumnRoles::default(), composition, |_| 0.0); - let length = if has_dropped { dropped } else { jump }; ramps.retarget_column( column, with_column(ramps.target(), column, target.0), - length, + landing, ); if let Some(silent) = follow.silent.get_mut(column) { *silent = target.1; } continue; }; - let mut gate_moved = false; - // The longest own length of the gates that moved (R19-4): the mute length for the - // route's own mute and the source mute, `VcaMute` for a VCA mute (#1655 D4). - let mut gate_length = 0_u32; - let mut any_placed = false; - // The longest own length of a placed value whose curve moved from the value the - // column heard (a landing coincident with a removed row's, R19-4). - let mut moved = 0_u32; - for role in 0..ROLES { - let placed = walk - .placement - .get(role) - .is_some_and(|&p| p != Placement::Continued); - let (Some(index), true) = (walk.roles.of(role), placed) else { - continue; - }; + let roles = walk.roles; + // The own mute gate and (#1655 D4) the VCA mute gates that do not continue are placed + // at `at` with their curves' values. + let placed = roles + .own + .into_iter() + .chain(roles.vca().filter(|_| !walk.vca_continued)); + for index in placed { let Some(cursor) = walk.walk.cursor_mut(index) else { continue; }; - let value_row = cursor.kind() == automation::CursorKind::Value; - any_placed |= value_row; - let own = lengths.for_row(ColumnRoles::row(role)); - // A new value cursor was placed before the cell (`place_new_values`); placing it - // again would drop the deferral a join record's column jump gave it. - if walk.placement.get(role) == Some(&Placement::New) && value_row { - moved = core::cmp::max(moved, own); - continue; - } let Some(cell) = follow.program.cell(cursor.cell() as usize) else { continue; }; let value = automation::value_at(cell.segments(), at); cursor.state_mut().reposition(&cell, at); - if cursor.kind() == automation::CursorKind::Gate { - // The source mute is one of the follow curves, compared as one below. - if role != SOURCE_MUTE && (cursor.heard() != 0.0) != (value != 0.0) { - gate_moved = true; - gate_length = core::cmp::max(gate_length, own); - } - cursor.state_mut().seed_target(value); - } else if cursor.state().target().map(f32::to_bits) != Some(value.to_bits()) { - moved = core::cmp::max(moved, own); - } + cursor.state_mut().seed_target(value); cursor.set_heard(value); } - let roles = walk.roles; - // #1655 D4: the VCA mute gates that do not continue are placed as the source mute. - if walk.vca_placement != Placement::Continued { - for index in roles.vca() { - let Some(cursor) = walk.walk.cursor_mut(index) else { - continue; - }; - let Some(cell) = follow.program.cell(cursor.cell() as usize) else { - continue; - }; - let value = automation::value_at(cell.segments(), at); - cursor.state_mut().reposition(&cell, at); - cursor.state_mut().seed_target(value); - cursor.set_heard(value); - } - } // The follow curves (own mute OR every VCA mute) are one gate: it moved when its OR // differs from the one the predecessor heard (#1655 D4; with the own mute alone, // that cursor's heard value against its curve, as before). // Its length is the longest among the follow curves that close it (a close) or, for // an open, among all of them (which ones were closed is not kept across a carry). + let mut gate_length = None; if roles.has_follow() { let heard = follow.follow_heard.get(column).copied().unwrap_or(false); let now = walk.heard_follow(); if now != heard { - gate_moved = true; let cursors = walk.walk.cursors(); let counts = |index: Option| { index.is_some_and(|index| { @@ -2497,69 +2195,38 @@ impl LiveFollowRoute { .is_some_and(|cursor| cursor.heard() != 0.0) }) }; - if counts(roles.of(SOURCE_MUTE)) { - gate_length = core::cmp::max( - gate_length, + let mut length = 0_u32; + if counts(roles.own) { + length = core::cmp::max( + length, lengths.for_row(crate::GraphRouteCursorRow::SourceMute), ); } if roles.vca().any(|index| counts(Some(index))) { - gate_length = core::cmp::max(gate_length, lengths.vca_mute); + length = core::cmp::max(length, lengths.vca_mute); } + gate_length = Some(length); } } let target = walk.walk.compose_heard(|heard| { column_target(column, roles, composition, |index| heard.value(index)) }); if same_entries(adopted, target.0) { - for_placed_values(walk, |cursor| { - let heard = cursor.heard(); - cursor.state_mut().seed_target(heard); - // R19-5 (MAJOR-2): a move in flight keeps its length; the repositioned - // cursor waits for its end, where it lands if its curve moved. - if remaining > 0 { - cursor.state_mut().defer_jump(free); - } - }); walk.walk.seed_target(target); - if let Some(silent) = follow.silent.get_mut(column) { - *silent = target.1; - } - } else if gate_moved { - // Coincident with a removed row's landing, the longer length (R19-4). - let length = core::cmp::max(gate_length, dropped); - ramps.retarget_column( - column, - with_column(ramps.target(), column, target.0), - length, - ); - walk.walk.retarget(target, at, length); - if let Some(silent) = follow.silent.get_mut(column) { - *silent = target.1; - } - } else if has_dropped || !any_placed { - // MAJOR-3: the column moved with no cursor whose curve moved (a removed row or - // source mute curve): it lands at once over the removed row's length, a column - // jump (R19-3), cutting a move in flight (B19-R7, flagged by the control plane). - // A placed value whose curve moved lands in it, with the longer length (R19-4). - let length = core::cmp::max(if has_dropped { dropped } else { jump }, moved); - for_placed_values(walk, |cursor| { - let heard = cursor.heard(); - cursor.state_mut().seed_target(heard); - }); + } else { + // A gate move coincident with a removed curve's landing takes the longer length + // (R19-4); a move with no gate move lands over the removed curve's length (or the + // mute length), cutting a move in flight (B19-R7, flagged by the control plane). + let length = gate_length.map_or(landing, |length| core::cmp::max(length, dropped)); ramps.retarget_column( column, with_column(ramps.target(), column, target.0), length, ); walk.walk.retarget(target, at, length); - if let Some(silent) = follow.silent.get_mut(column) { - *silent = target.1; - } - } else { - let silent = follow.silent.get(column).copied().unwrap_or(false); - walk.walk.seed_target((adopted, silent)); - for_placed_values(walk, |cursor| cursor.state_mut().defer_jump(free)); + } + if let Some(silent) = follow.silent.get_mut(column) { + *silent = target.1; } } *mute = follow.silent[0] && follow.silent[1]; @@ -2590,9 +2257,6 @@ impl LiveFollowRoute { (FollowStart::Carried, Some(at)) => Some(at), _ => None, }; - if let Some(at) = carried { - self.follow.place_new_values(at); - } self.apply_cell(snapshot, block.start); if let Some(at) = carried { self.place_carried(at); @@ -2612,13 +2276,11 @@ impl LiveFollowRoute { program, columns, silent: heard_silent, - gain_law, .. } = follow; let composition = Composition { transform, flags: *flags, - law: *gain_law, }; for column in columns.iter_mut().flatten() { let _copied = column.probe.copy_from(&column.walk); @@ -2684,13 +2346,11 @@ impl LiveFollowRoute { program, columns, silent, - gain_law, .. } = follow; let composition = Composition { transform, flags: *flags, - law: *gain_law, }; let [zero, one] = columns; walk_columns( @@ -9585,10 +9245,8 @@ 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, following, automated) = match &op.kind { - NodeKind::LiveRoute(live) => { - (live.route, false, crate::RouteAutomatedSet::default()) - } - NodeKind::LiveFollowRoute(live) => (live.route, true, live.flags.automated_set()), + NodeKind::LiveRoute(live) => (live.route, false, [false; 2]), + NodeKind::LiveFollowRoute(live) => (live.route, true, live.flags.automated), _ => return None, }; let GraphNodeId::Route { route_id } = &route_gates.get(route as usize)?.0 else { @@ -19757,23 +19415,23 @@ mod follow_route_op_tests { clippy::needless_range_loop, reason = "block and column indices name node times and columns in the assertions" )] -mod send_automation_tests { - //! #1654 T3: the following route op over a send's own stored rows (gain, mute, matrix) on the - //! merged walk (K1), driven at its block entry and its mix from graph fixtures. The route op - //! is not banked and mixes at `FrameLane` on every backend, so no case depends on a bank - //! width. - use super::{ColumnRamps, FrameLane, IndexedRamp, LiveFollowRoute, route_mix_ramp_block}; +mod follow_curve_tests { + //! #1654 T3 and its folds, #1655 D4 and D6: the following route op on the merged walk (K1) + //! over the follow curves a send hears (its source lanes' own mutes and the reaching VCA + //! mutes), driven at its block entry and its mix from graph fixtures, across hand moves, + //! carries and seeks. The route op is not banked and mixes at `FrameLane` on every backend, so + //! no case depends on a bank width. + use super::{IndexedRamp, LiveFollowRoute}; use crate::follow_route_tests::route_jumps; use crate::{ - FollowRouteControlRecord, GRAPH_ROUTE_ROWS, GraphBlockTime, GraphFollowRouteSource, - GraphJumpLengths, GraphPreparedSourceSetDriver, RenderError, RouteFollowFlags, RouteGate, - RouteTransform, StableGraphId, TimelineSpan, gated_route_coefficients, + FollowRouteControlRecord, GraphBlockTime, GraphFollowRouteSource, GraphJumpLengths, + GraphPreparedSourceSetDriver, RenderError, RouteFollowFlags, RouteGate, RouteTransform, + StableGraphId, TimelineSpan, gated_route_coefficients, }; use automation::{Segment, Shape}; use engine::realtime::{GatePin, LiveValuesSuperseded, RevisionGate}; - /// The send's lengths: the mute rule 200, `SendGain` 480, `SendMatrix` 960 - /// ([`route_jumps`] maps them from the fader, mute and pan lengths). + /// The send's lengths: the mute rule 200 ([`route_jumps`] maps it from the mute length). const LENGTHS: GraphJumpLengths = GraphJumpLengths { fader_samples: 480, mute_samples: 200, @@ -19807,12 +19465,6 @@ mod send_automation_tests { } } - /// The route's dB law in these cases: a gain linear in `curve + offset` (graph links no - /// `math`), summed in `f64` and rounded once, as `graph_compiler::route_gain_law` shapes it. - fn law(curve_db: f32, offset_db: f32) -> f32 { - (1.0 + (f64::from(curve_db) + f64::from(offset_db)) / 48.0) as f32 - } - const fn seg(start: u64, end: u64, v0: f32, v1: f32, shape: Shape) -> Segment { Segment { start, @@ -19832,15 +19484,11 @@ mod send_automation_tests { vec![seg(0, at, before, after, Shape::Step)] } - /// The curves a send hears: its source lanes' mutes, its gain, its own mute and its matrix - /// entries `[ll, lr, rl, rr]`; per source lane, the mute curves of the automated VCAs that - /// reach it, in ascending VCA ID (#1655 D4). - #[derive(Default)] + /// The curves a send hears: its source lanes' own mutes and, per source lane, the mute curves + /// of the automated VCAs that reach it, in ascending VCA ID (#1655 D4). + #[derive(Clone, Default)] struct Curves { source: [Option>; 2], - gain: Option>, - mute: Option>, - matrix: [Option>; 4], vca: [Vec>; 2], /// The tap's arrival `a(tap)`. arrival: u64, @@ -19870,38 +19518,19 @@ mod send_automation_tests { impl Send { /// A route of `transform` over `curves`, at `curves.arrival`, following its source. fn new(curves: &Curves, transform: RouteTransform) -> Self { - Self::continuing(curves, transform, [false; GRAPH_ROUTE_ROWS]) - } - - /// [`Self::new`] whose route rows `continued` continue the predecessor's tables. - fn continuing( - curves: &Curves, - transform: RouteTransform, - continued: [bool; GRAPH_ROUTE_ROWS], - ) -> Self { - Self::continuing_vca(curves, transform, continued, [false; 2]) + Self::continuing_vca(curves, transform, [false; 2]) } - /// [`Self::continuing`] whose source lanes `continued_vca` continue the predecessor's VCA - /// mute tables (#1655 D6). + /// [`Self::new`] whose source lanes `continued_vca` continue the predecessor's VCA mute + /// tables (#1655 D6). fn continuing_vca( curves: &Curves, transform: RouteTransform, - continued: [bool; GRAPH_ROUTE_ROWS], continued_vca: [bool; 2], ) -> Self { - let rows: [&Option>; GRAPH_ROUTE_ROWS] = [ - &curves.gain, - &curves.mute, - &curves.matrix[0], - &curves.matrix[1], - &curves.matrix[2], - &curves.matrix[3], - ]; let all: Vec<&Vec> = curves .source .iter() - .chain(rows.iter().copied()) .flatten() .chain(curves.vca.iter().flatten()) .collect(); @@ -19927,7 +19556,6 @@ mod send_automation_tests { }) }; let cells = [index(&curves.source[0]), index(&curves.source[1])]; - let route_cells = rows.map(&mut index); let vca_mutes = curves.vca.each_ref().map(|lane| { lane.iter() .map(|_| { @@ -19936,35 +19564,21 @@ mod send_automation_tests { }) .collect::>() }); - let [gain, mute, ll, lr, rl, rr] = route_cells.map(|cell| cell.is_some()); let flags = RouteFollowFlags { follows_mute: true, automated: [0, 1].map(|lane| cells[lane].is_some() || !vca_mutes[lane].is_empty()), - automated_gain: gain, - automated_mute: mute, - automated_matrix: [ll, lr, rl, rr], ..RouteFollowFlags::default() }; let source = GraphFollowRouteSource::new( program, cells, vca_mutes, - route_cells, curves.arrival, send_jumps, - law, flags, ) .expect("source") - .with_continued_rows(continued) .with_continued_vca_mutes(continued_vca); - // The cell's `gain` word of an automated gain is the hand offset: the harness's - // `transform.gain` seeds it. - let source = if gain { - source.with_gain_offset(transform.gain).expect("offset") - } else { - source - }; let (binding, producer) = crate::follow_route_control_pair( &StableGraphId::parse("send").expect("ID"), &LiveValuesSuperseded::new(), @@ -19990,32 +19604,47 @@ mod send_automation_tests { } } - /// Write and publish one record: `transform`, the route's own flags, over `length`. - fn write(&mut self, transform: RouteTransform, length: u32) { + /// Write and publish one record: `transform` and `flags` over `length`. + fn write_with(&mut self, transform: RouteTransform, flags: RouteFollowFlags, length: u32) { self.revision += 1; - let record = - FollowRouteControlRecord::new(transform, self.route.flags, length).expect("record"); + let record = FollowRouteControlRecord::new(transform, flags, length).expect("record"); self.producer .write(self.pin.stamp(self.revision).expect("stamp"), record); self.pin.publish(&self.gate, self.revision); } + /// Write and publish one record: `transform`, the route's own flags, over `length` (a + /// live send move, D6). + fn write(&mut self, transform: RouteTransform, length: u32) { + let flags = self.route.flags; + self.write_with(transform, flags, length); + } + /// The route op's block of `left.len()` samples whose first sample reads node time - /// `first`: its block entry, then its mix when active. - fn block(&mut self, first: u64, left: &mut [f32], right: &mut [f32]) -> Option { + /// `first` through `driver`: its block entry, then its mix when active. + fn block_with( + &mut self, + first: u64, + lengths: GraphJumpLengths, + driver: &dyn GraphPreparedSourceSetDriver, + left: &mut [f32], + right: &mut [f32], + ) { let frames = u32::try_from(left.len()).expect("frames"); - 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() { + let time = GraphBlockTime::new(Some(first), frames, Some(lengths), Some(driver)); + if self + .route + .begin_block(self.gate.snapshot(), &time) + .is_some() + { self.route.mix(left, right); } - from } /// One block of [`QUANTUM`] samples from `first` over a fixed input; its output bits. fn run(&mut self, first: u64) -> Vec { let (mut left, mut right) = input(QUANTUM); - self.block(first, &mut left, &mut right); + self.block_with(first, LENGTHS, &Timeline, &mut left, &mut right); bits((&left, &right)) } } @@ -20040,7 +19669,7 @@ mod send_automation_tests { } const OPEN: RouteTransform = RouteTransform { - gain: 0.0, + gain: 0.5, ll: 0.9, lr: 0.2, rl: -0.3, @@ -20052,58 +19681,16 @@ mod send_automation_tests { gated_route_coefficients(&RouteTransform { gain, ..transform }, RouteGate::OPEN) } - /// Gate 1 (flat equals static, batch 10 amendment): a send whose gain rides flat at -6 dB with - /// a hand offset of `+0.0` or `-0.0`, whose `ll` and `rr` ride flat and whose own mute rides - /// flat open binds and mixes, block after block, the bits of a route with those static values - /// (`law(-6, -0.0)`, the static gain's own law, and the curves' matrix entries; the cell's - /// fallback words are not heard). - /// - /// Red if the route op reads the offset word as a linear gain, hears an automated matrix - /// entry's fallback, converts the curve with other arithmetic than the law, or retargets on a - /// flat ride. - #[test] - fn a_flat_send_ride_mixes_the_static_route_bits() { - let curves = Curves { - gain: Some(flat(-6.0)), - mute: Some(flat(0.0)), - matrix: [Some(flat(0.9)), None, None, Some(flat(0.8))], - ..Curves::default() - }; - let fixed = RouteTransform { - gain: law(-6.0, -0.0), - ..OPEN - }; - let expected_coefficients = gated_route_coefficients(&fixed, RouteGate::OPEN); - for offset in [0.0_f32, -0.0] { - let mut send = Send::new( - &curves, - RouteTransform { - gain: offset, - ll: 0.123, - rr: 0.456, - ..OPEN - }, - ); - assert_eq!( - send.route.ramps, - ColumnRamps::settled(expected_coefficients), - "offset {offset:?}: the bound target" - ); - let settled = IndexedRamp::settled(expected_coefficients); - for index in 0..40_u64 { - let (mut left, mut right) = input(QUANTUM); - route_mix_ramp_block::(&mut left, &mut right, &settled, 0); - assert_eq!( - send.run(index * QUANTUM as u64), - bits((&left, &right)), - "offset {offset:?}, block {index}" - ); - } - assert_eq!( - send.route.ramps, - ColumnRamps::settled(expected_coefficients) - ); - } + /// The coefficients of `transform` with linear gain `gain` whose follow term zeroes the + /// columns `zeroed`. + fn zeroed(transform: RouteTransform, gain: f32, zeroed: [bool; 2]) -> [f32; 4] { + gated_route_coefficients( + &RouteTransform { gain, ..transform }, + RouteGate { + mute: false, + follow_zeroed: zeroed, + }, + ) } /// The route's column ramps after each block of a run from node time 0, by block. @@ -20125,590 +19712,148 @@ mod send_automation_tests { .all(|&index| ramp.target[index].to_bits() == target[index].to_bits()) } - /// R19-3 gate (a): a send's gain riding across an unmute of its own mute (1 -> 0 at 1,000): - /// the unmute retargets both columns at 1,000 over the mute length (200) toward the gain the - /// route heard; no gain grid event inside the fade retargets either column, so the fade ends - /// at exactly 1,200; there the gain lands over its own length (480) on the curve at the - /// landing's last frame (1,679); its grid moves the columns again from the first grid point - /// whose ramp ends after the landing (1,664), over the grid ramp. + /// One plan swap at the start of block `at` (#1654 D3): `successor` takes `predecessor`'s + /// route state, as `carry_live_routes` does per move. + fn swap(successor: &mut Send, predecessor: &mut Send) { + let pending = super::carry_route( + super::CarriedRoute::Seven(&mut successor.route), + super::CarriedRoute::Seven(&mut predecessor.route), + None, + ); + assert!(!pending); + } + + /// The predecessor of a carry: `curves` under `base`, a live send move to `moved` over 2,000 + /// samples written at block 18 (node time 1,152), run to the swap at block 20 (1,280). + fn moving(curves: &Curves, base: RouteTransform, moved: RouteTransform) -> Send { + let mut predecessor = Send::new(curves, base); + for index in 0..18_u64 { + predecessor.run(index * 64); + } + predecessor.write(moved, 2_000); + for index in 18..20_u64 { + predecessor.run(index * 64); + } + predecessor + } + + /// R19-4 on the follow curves: the source lane's own mute and a reaching VCA mute both close + /// the left column at one offset (1,000); the column takes one retarget over the longer of + /// the two own lengths, whichever curve owns it (the `VcaMute` 320 against a mute length of + /// 200, and the mute length 480 against 320). /// - /// Red if a grid event cuts the column jump, if the held move is not landed at its end, or if - /// it lands over the mute length instead of the gain's. + /// Red if coincident gate events take one curve's length regardless, or retarget twice. #[test] - fn a_gain_ride_across_an_unmute_fade_ends_the_fade_at_its_length_and_lands_at_its_end() { - let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; + fn coincident_follow_curve_events_take_the_longest_jump() { let curves = Curves { - gain: Some(ride.clone()), - mute: Some(step(1_000, 1.0, 0.0)), + source: [Some(step(1_000, 0.0, 1.0)), None], + vca: [vec![step(1_000, 0.0, 1.0)], Vec::new()], ..Curves::default() }; - let mut send = Send::new(&curves, OPEN); - let curve = |t: i64| automation::value_at(&ride, t); - let by_block = ramps_by_block(&mut send, 30); - let fade = by_block[1_000 / QUANTUM]; - // The unmute aims at the gain the route heard: the grid event at 960, toward 1,023. - let heard = open(OPEN, law(curve(1_023), 0.0)); - for column in 0..2 { - assert!( - aims(&fade[column], heard, 200, column), - "the fade, column {column}" - ); - } - for block in 1_000 / QUANTUM..1_200 / QUANTUM { - assert_eq!(by_block[block], fade, "block {block}: the fade runs whole"); - } - let landing = by_block[1_200 / QUANTUM]; - let landed = open(OPEN, law(curve(1_200 + 479), 0.0)); - for column in 0..2 { - assert!( - aims(&landing[column], landed, 480, column), - "the landing, column {column}" - ); - let ended = fade[column].coefficients_at(200); + let closed = zeroed(OPEN, 0.5, [true, false]); + for (mute, longest) in [(200, VCA_MUTE), (480, 480)] { + let lengths = GraphJumpLengths { + mute_samples: mute, + ..LENGTHS + }; + let mut send = Send::new(&curves, OPEN); + let mut ramps = Vec::new(); + for index in 0..30_u64 { + let (mut left, mut right) = input(QUANTUM); + send.block_with( + index * QUANTUM as u64, + lengths, + &Timeline, + &mut left, + &mut right, + ); + ramps.push(send.route.ramps); + } + let jump = ramps[1_000 / QUANTUM]; assert!( - super::COLUMNS[column] - .iter() - .all(|&index| landing[column].start[index].to_bits() == ended[index].to_bits()), - "the landing starts where the fade ended, column {column}" + aims(&jump.ramps[0], closed, longest, 0), + "mute {mute}: {:?} over {}", + jump.ramps[0].target, + jump.ramps[0].length ); - } - for block in 1_216 / QUANTUM..1_664 / QUANTUM { - assert_eq!(by_block[block], landing, "block {block}: the landing runs"); - } - let grid = by_block[1_664 / QUANTUM]; - for column in 0..2 { - assert!( - aims( - &grid[column], - open(OPEN, law(curve(1_664 + 63), 0.0)), - 64, - column - ), - "the grid moves the column again, column {column}" + assert_eq!( + u64::from(jump.positions[0]), + QUANTUM as u64 - 1_000 % QUANTUM as u64, + "mute {mute}: one retarget at 1,000" ); + for later in &ramps[1_000 / QUANTUM + 1..] { + assert_eq!(later.ramps, jump.ramps, "mute {mute}: no second retarget"); + } } } - /// R19-3 gate (b): with the left column's `ll` riding and the source's right mute stepping on - /// at 1,000, the right column fades over the mute length while the left column keeps - /// retargeting at every grid point over the grid ramp: the hold is per column. + /// R19-5 on a mute-following send: a live send move (gain 0.5 -> 1.0 over 2,000 from 1,152) + /// in flight at a swap (1,280) whose follow curves are unchanged (the own mute and a VCA mute + /// are placed again at the successor's node time) keeps its own length: every block up to + /// and past the move's end is the never-swapped twin's, bit for bit. /// - /// Red if a column jump of one column holds the other column's grid (a per-route hold). + /// Red if the carry retargets the column when its composition equals the adopted target + /// (the move would be cut to the mute length at the swap). #[test] - fn a_ride_on_the_left_column_keeps_moving_while_the_right_column_fades() { - let ride = vec![seg(0, 8_000, 0.1, 0.9, Shape::Linear)]; + fn a_live_send_move_in_flight_at_a_swap_keeps_its_length() { let curves = Curves { - source: [None, Some(step(1_000, 0.0, 1.0))], - matrix: [Some(ride.clone()), None, None, None], + source: [Some(step(4_000, 0.0, 1.0)), None], + vca: [Vec::new(), vec![step(4_000, 0.0, 1.0)]], ..Curves::default() }; - let transform = RouteTransform { gain: 0.5, ..OPEN }; - let mut send = Send::new(&curves, transform); - let by_block = ramps_by_block(&mut send, 20); - let fade = by_block[1_000 / QUANTUM][1]; - assert_eq!( - fade.length, 200, - "the right column fades over the mute length" - ); - assert_eq!(fade.target[1].to_bits(), 0.0_f32.to_bits()); - assert_eq!(fade.target[3].to_bits(), 0.0_f32.to_bits()); - for block in 1_000 / QUANTUM..1_200 / QUANTUM { + let moved = RouteTransform { gain: 1.0, ..OPEN }; + let mut twin = moving(&curves, OPEN, moved); + let mut predecessor = moving(&curves, OPEN, moved); + let mut successor = Send::new(&curves, moved); + swap(&mut successor, &mut predecessor); + for index in 20..80_u64 { assert_eq!( - by_block[block][1], fade, - "block {block}: the right column fades" - ); - let left = by_block[block][0]; - let grid = (block * QUANTUM) as i64; - let ll = automation::value_at(&ride, grid + 63); - let target = - gated_route_coefficients(&RouteTransform { ll, ..transform }, RouteGate::OPEN); - assert!( - aims(&left, target, 64, 0), - "block {block}: the left column rides on" + successor.run(index * 64), + twin.run(index * 64), + "block {index}" ); } } - /// R19-4 gate: an unmute of the send's own mute and a step of its gain at one offset (1,000) - /// are one retarget of each column, over the longer of the two lengths, whichever row owns it. + /// U2 fold 1, MAJOR-3: a send whose left lane loses its own mute curve at a swap (the right + /// lane keeps its own), settled. No cursor's curve carries the move and no join record moves + /// the cell, so the carry itself lands the left column on the cell's words at the + /// successor's first sample, over the removed curve's own length (the mute length, 200); a + /// removed VCA mute lands over the `VcaMute` length (320); the right column keeps its ramp. /// - /// Red if coincident events take one row's length regardless, or retarget twice. + /// Red if the column stays on the removed curve's last value, or lands over another length. #[test] - fn coincident_send_events_take_the_longest_jump() { - let curves = Curves { - gain: Some(vec![ - seg(0, 1_000, -6.0, -6.0, Shape::Step), - seg(1_000, 9_000, -12.0, -12.0, Shape::Step), - ]), - mute: Some(step(1_000, 1.0, 0.0)), - ..Curves::default() - }; - for (lengths, longest) in [((480, 200), 480), ((480, 960), 960)] { - let mut send = Send::new(&curves, OPEN); - let lengths = GraphJumpLengths { - fader_samples: lengths.0, - mute_samples: lengths.1, - pan_samples: 960, - }; - let mut ramps = Vec::new(); - for index in 0..20_u64 { - let (mut left, mut right) = input(QUANTUM); - let time = GraphBlockTime::new( - Some(index * QUANTUM as u64), - QUANTUM as u32, - Some(lengths), - Some(&Timeline), - ); - if send - .route - .begin_block(send.gate.snapshot(), &time) - .is_some() - { - send.route.mix(&mut left, &mut right); - } - ramps.push(send.route.ramps.ramps); - } - let target = open(OPEN, law(-12.0, 0.0)); - let jump = ramps[1_000 / QUANTUM]; - for column in 0..2 { - assert!(aims(&jump[column], target, longest, column), "{lengths:?}"); - assert!( - super::COLUMNS[column] - .iter() - .all(|&index| jump[column].start[index].to_bits() == 0.0_f32.to_bits()), - "{lengths:?}: column {column} from silence" - ); - } - assert!( - ramps[1_000 / QUANTUM + 1..] - .iter() - .all(|ramp| *ramp == jump) - ); - } - } - - /// R19-3, a value jump inside a column jump (coordinator question, batch 19): the send's own - /// unmute at 1,000 holds the riding `ll` until 1,200; the gain steps at 1,100 and jumps over its - /// own length (480, to 1,579), replacing the column ramp. The held `ll` waits for the new - /// ramp's end: nothing retargets the left column in between, and at 1,580 `ll` lands over the - /// matrix length (960). - /// - /// Red if a value cursor deferred to a replaced column ramp's end lands at that old end, inside - /// another curve's jump (a re-time of the gain step). - #[test] - fn a_held_value_waits_for_the_end_of_a_jump_that_replaced_the_column_ramp() { - let ride = vec![seg(0, 8_000, 0.1, 0.9, Shape::Linear)]; - let curves = Curves { - gain: Some(vec![ - seg(0, 1_100, -6.0, -6.0, Shape::Step), - seg(1_100, 9_000, -12.0, -12.0, Shape::Step), - ]), - mute: Some(step(1_000, 1.0, 0.0)), - matrix: [Some(ride.clone()), None, None, None], - ..Curves::default() - }; - let mut send = Send::new(&curves, OPEN); - let by_block = ramps_by_block(&mut send, 30); - let step = by_block[1_100 / QUANTUM][0]; - assert_eq!(step.length, 480, "the gain step jumps over its own length"); - for block in 1_100 / QUANTUM..1_580 / QUANTUM { - assert_eq!( - by_block[block][0], step, - "block {block}: the gain's jump runs whole" - ); - } - let landing = by_block[1_580 / QUANTUM][0]; - let ll = automation::value_at(&ride, 1_580 + 959); - let target = open(RouteTransform { ll, ..OPEN }, law(-12.0, 0.0)); - assert!( - aims(&landing, target, 960, 0), - "the held ll lands at the jump's end" - ); - } - - /// A hand offset record on a riding send gain (+3 dB over 256 at node time 2,048, #1654 R17-6 - /// on the route, #1649 D3): both columns retarget at the block entry toward the curve at the - /// record's last frame with the new offset, the grid is held through the record's ramp, and - /// at its end (2,304) the gain lands over its own length. - /// - /// Red if the record aims at the curve at its first frame, if the grid cuts the record's ramp, - /// or if the held ride is not landed at its end. - #[test] - fn a_hand_offset_record_aims_at_the_curve_at_its_end_and_holds_the_grid() { - let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; - let curves = Curves { - gain: Some(ride.clone()), - ..Curves::default() - }; - let curve = |t: i64| automation::value_at(&ride, t); - let mut send = Send::new(&curves, OPEN); - let mut ramps = ramps_by_block(&mut send, 32); - send.write(RouteTransform { gain: 3.0, ..OPEN }, 256); - ramps.extend((32..48).map(|index| { - send.run(index * QUANTUM as u64); - send.route.ramps.ramps - })); - let record = ramps[32]; - let aimed = open(OPEN, law(curve(2_048 + 255), 3.0)); - for column in 0..2 { - assert!( - aims(&record[column], aimed, 256, column), - "the record, column {column}" - ); - } - for block in 33..36 { - assert_eq!( - ramps[block], record, - "block {block}: the record's ramp runs whole" - ); - } - let landing = ramps[36]; - let landed = open(OPEN, law(curve(2_304 + 479), 3.0)); - for column in 0..2 { - assert!( - aims(&landing[column], landed, 480, column), - "the landing, column {column}" - ); - } - } - - /// One plan swap at the start of block `at` (#1654 D3): `successor` takes `predecessor`'s - /// route state, as `carry_live_routes` does per move. - fn swap(successor: &mut Send, predecessor: &mut Send) { - let pending = super::carry_route( - super::CarriedRoute::Seven(&mut successor.route), - super::CarriedRoute::Seven(&mut predecessor.route), - None, - ); - assert!(!pending); - } - - /// D3, a continued table: a send whose gain rides, carried mid-ride into a successor whose - /// gain row continues the table, keeps its cursor verbatim: every block after the swap is the - /// never-swapped twin's, bit for bit. - /// - /// Red if a continued cursor is placed again at the swap (its ride would jump over the gain - /// length), or if its state or heard value is not carried. - #[test] - fn a_continued_send_ride_carries_its_cursor_verbatim() { - let curves = Curves { - gain: Some(vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]), - matrix: [ - None, - Some(vec![seg(0, 8_000, 0.2, 0.7, Shape::Linear)]), - None, - None, - ], - ..Curves::default() - }; - let mut twin = Send::new(&curves, OPEN); - let mut predecessor = Send::new(&curves, OPEN); - for index in 0..21_u64 { - assert_eq!(twin.run(index * 64), predecessor.run(index * 64)); - } - let mut successor = - Send::continuing(&curves, OPEN, [true, false, false, true, false, false]); - swap(&mut successor, &mut predecessor); - for index in 21..60_u64 { - assert_eq!( - successor.run(index * 64), - twin.run(index * 64), - "block {index}" - ); - } - } - - /// D3, a moved table: a settled send whose gain table changes at a swap (flat -6 dB to flat - /// -3 dB, the row not continued) jumps at the successor's first sample over the gain length. - /// - /// Red if the moved row lands as a step, over another row's length, or not at all (the - /// cursor's comparison with the placed value instead of the value the column heard). - #[test] - fn a_moved_send_table_jumps_over_its_own_length_at_the_first_sample() { - let before = Curves { - gain: Some(flat(-6.0)), - ..Curves::default() - }; - let after = Curves { - gain: Some(flat(-3.0)), - ..Curves::default() - }; - let mut predecessor = Send::new(&before, OPEN); - for index in 0..20_u64 { - predecessor.run(index * 64); - } - let mut successor = Send::new(&after, OPEN); - swap(&mut successor, &mut predecessor); - successor.run(20 * 64); - let target = open(OPEN, law(-3.0, 0.0)); - for column in 0..2 { - let ramp = successor.route.ramps.ramps[column]; - assert!(aims(&ramp, target, 480, column), "column {column}"); - assert_eq!(successor.route.ramps.positions[column], 64); - } - } - - /// R19-5: a hand offset record in flight at a swap whose gain table moves (flat -6 dB to flat - /// -3 dB) keeps its own length: the column ramp runs to its end (3,152), where the moved row - /// jumps over the gain length to the new table with the offset (#1711's deferred jump, per - /// column, no flag). - /// - /// Red if the swap lands the moved table at once (cutting the hand move), or never. - #[test] - fn a_hand_move_in_flight_at_a_swap_keeps_its_length_and_the_moved_table_lands_after_it() { - let before = Curves { - gain: Some(flat(-6.0)), - ..Curves::default() - }; - let after = Curves { - gain: Some(flat(-3.0)), - ..Curves::default() - }; - let moved = RouteTransform { gain: 3.0, ..OPEN }; - let mut predecessor = Send::new(&before, OPEN); - for index in 0..18_u64 { - predecessor.run(index * 64); - } - predecessor.write(moved, 2_000); - for index in 18..20_u64 { - predecessor.run(index * 64); - } - let record = predecessor.route.ramps.ramps; - let mut successor = Send::new(&after, moved); - swap(&mut successor, &mut predecessor); - for index in 20..49_u64 { - successor.run(index * 64); - assert_eq!( - successor.route.ramps.ramps, record, - "block {index}: the move runs" - ); - } - successor.run(49 * 64); - let target = open(OPEN, law(-3.0, 3.0)); - for column in 0..2 { - let ramp = successor.route.ramps.ramps[column]; - assert!(aims(&ramp, target, 480, column), "column {column}"); - // The jump is at 3,152, offset 16 of the block from 3,136. - assert_eq!(successor.route.ramps.positions[column], 48); - } - } - - /// R19-5 with riding tables: a hand offset record in flight at a swap whose gain ride's table - /// moves keeps its own length: until its end every block is the never-swapped twin's. - /// - /// Red if the repositioned cursor lands the moved table inside the hand move. - #[test] - fn a_hand_move_in_flight_at_a_swap_of_a_riding_table_is_the_twin_until_its_end() { - let ride = |end_db: f32| Curves { - gain: Some(vec![seg(0, 12_288, 0.0, end_db, Shape::Linear)]), - ..Curves::default() - }; - let moved = RouteTransform { gain: 3.0, ..OPEN }; - let mut twin = Send::new(&ride(-12.0), OPEN); - let mut predecessor = Send::new(&ride(-12.0), OPEN); - for index in 0..4_u64 { - assert_eq!(twin.run(index * 64), predecessor.run(index * 64)); - } - twin.write(moved, 4_000); - predecessor.write(moved, 4_000); - for index in 4..6_u64 { - assert_eq!(twin.run(index * 64), predecessor.run(index * 64)); - } - let mut successor = Send::new(&ride(-24.0), moved); - swap(&mut successor, &mut predecessor); - // The move started at 256 and ends at 4,256: block 66 of 64 holds its end. - for index in 6..66_u64 { - assert_eq!( - successor.run(index * 64), - twin.run(index * 64), - "block {index}" - ); - } - } - - /// T5, a row the predecessor did not automate: a send muted by hand whose successor gains a - /// gain ride and is unmuted in the same rebuild (a gate-only join record on the carried first - /// block) aims its unmute at the ride's value at the first block's node time, the value a - /// fresh plan hears there, not at the ride's value at node time 0 (the new cursor's bind - /// seed), which no plan hears. - /// - /// Red if the record composes with the new cursor's bind seed (the unmute ramps to the gain - /// at 0 dB and only then jumps to the ride), or if the carry's placement drops the column - /// jump's hold, so the ride's grid retargets inside the unmute (R19-3). - #[test] - fn a_new_send_row_at_a_swap_composes_the_join_record_with_its_curve_at_the_first_block() { - let before = Curves { - mute: Some(flat(0.0)), - ..Curves::default() - }; - let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; - let after = Curves { - gain: Some(ride.clone()), - mute: Some(flat(0.0)), - ..Curves::default() - }; - let mut predecessor = Send::new(&before, OPEN); - let muted = RouteFollowFlags { - mute: true, - ..predecessor.route.flags - }; - predecessor.revision += 1; - let record = FollowRouteControlRecord::new(OPEN, muted, 0).expect("record"); - predecessor.producer.write( - predecessor.pin.stamp(predecessor.revision).expect("stamp"), - record, - ); - predecessor - .pin - .publish(&predecessor.gate, predecessor.revision); - for index in 0..40_u64 { - predecessor.run(index * 64); - } - assert_eq!(predecessor.route.ramps.target().map(f32::to_bits), [0; 4]); - let mut successor = Send::new(&after, OPEN); - swap(&mut successor, &mut predecessor); - let flags = successor.route.flags; - successor.revision += 1; - let record = FollowRouteControlRecord::new(OPEN, flags, 200).expect("record"); - successor.producer.write( - successor.pin.stamp(successor.revision).expect("stamp"), - record, - ); - successor.pin.publish(&successor.gate, successor.revision); - let expected = open(OPEN, law(automation::value_at(&ride, 40 * 64), 0.0)); - // The unmute's column jump runs to 2,760: the ride's grid is held through block 41. - for index in 40..42_u64 { - successor.run(index * 64); - assert_eq!( - successor.route.ramps.target().map(f32::to_bits), - expected.map(f32::to_bits), - "block {index}" - ); - } - } - - /// U2 fold 1, MAJOR-1 (R19-3: a held grid event updates the heard values): the send's own mute - /// closes at 1,000 (a column jump over the mute length, 200, to 1,200) and reopens at 1,100, - /// inside it, while the gain rides. The gain's grid events at 1,024 and 1,088 are held, but - /// heard: the reopen aims at the gain of the grid event at 1,088 (the curve at 1,151). - /// - /// Red if a held grid event is shown only, so the reopen composes the gain heard before the - /// close (the curve at 1,023). - #[test] - fn a_reopen_inside_a_gate_jump_composes_the_held_grid_values() { - let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; - let curves = Curves { - gain: Some(ride.clone()), - mute: Some(vec![ - seg(0, 1_000, 0.0, 1.0, Shape::Step), - seg(1_000, 1_100, 1.0, 0.0, Shape::Step), - ]), - ..Curves::default() - }; - let mut send = Send::new(&curves, OPEN); - let by_block = ramps_by_block(&mut send, 20); - let reopen = by_block[1_100 / QUANTUM]; - let heard = open(OPEN, law(automation::value_at(&ride, 1_151), 0.0)); - for column in 0..2 { - assert!( - aims(&reopen[column], heard, 200, column), - "the reopen, column {column}: {:?}", - reopen[column].target - ); - } - } - - /// U2 fold 1, MAJOR-2 (R19-5 with equal bits at the swap): a hand offset (+3 dB over 2,000 from - /// 1,152) is in flight at a swap (1,280) whose gain table stays at -6 dB until 2,000 and then - /// ramps. The composition at the swap equals the adopted target, so nothing lands then, but - /// the repositioned gain still waits for the move's end (3,152): its ramp's grid does not cut - /// the move, and at 3,152 it lands over the gain length. - /// - /// Red if the equal branch leaves the repositioned cursor free (the ramp's grid replaces the - /// 2,000-sample move from 2,000). - #[test] - fn a_hand_move_in_flight_at_an_equal_swap_keeps_its_length() { - let before = Curves { - gain: Some(flat(-6.0)), - ..Curves::default() - }; - let table = vec![ - seg(0, 2_000, -6.0, -6.0, Shape::Linear), - seg(2_000, 8_000, -6.0, -18.0, Shape::Linear), - ]; - let after = Curves { - gain: Some(table.clone()), - ..Curves::default() - }; - let moved = RouteTransform { gain: 3.0, ..OPEN }; - let mut predecessor = Send::new(&before, OPEN); - for index in 0..18_u64 { - predecessor.run(index * 64); - } - predecessor.write(moved, 2_000); - for index in 18..20_u64 { - predecessor.run(index * 64); - } - let record = predecessor.route.ramps.ramps; - let mut successor = Send::new(&after, moved); - swap(&mut successor, &mut predecessor); - for index in 20..49_u64 { - successor.run(index * 64); - assert_eq!( - successor.route.ramps.ramps, record, - "block {index}: the move runs" - ); - } - successor.run(49 * 64); - let target = open(OPEN, law(automation::value_at(&table, 3_152 + 479), 3.0)); - for column in 0..2 { - let ramp = successor.route.ramps.ramps[column]; - assert!(aims(&ramp, target, 480, column), "column {column}"); - assert_eq!(successor.route.ramps.positions[column], 48); - } - } - - /// U2 fold 1, MAJOR-3: a send whose gain row continues and whose `ll` row (or the source's - /// left mute curve) is removed at a swap, settled. The removed row has no cursor and no join - /// record moves the cell, so the carry itself lands the column on the static value at the - /// successor's first sample, over the removed row's own length (`SendMatrix` 960 for `ll`, - /// the mute length 200 for a mute curve); the other column keeps its ramp. - /// - /// Red if the column stays on the removed curve's last value. - #[test] - fn a_removed_send_row_lands_the_column_on_its_static_value() { - let gain = Some(flat(-6.0)); + fn a_removed_follow_curve_lands_the_column_on_its_static_value() { + let right = Some(flat(0.0)); let cases = [ ( Curves { - gain: gain.clone(), - matrix: [Some(flat(0.5)), None, None, None], + source: [Some(flat(1.0)), right.clone()], ..Curves::default() }, - 960, + 200, ), ( Curves { - source: [Some(flat(1.0)), None], - gain: gain.clone(), + source: [None, right.clone()], + vca: [vec![flat(1.0)], Vec::new()], ..Curves::default() }, - 200, + VCA_MUTE, ), ]; let after = Curves { - gain, + source: [None, right], ..Curves::default() }; - let target = open(OPEN, law(-6.0, 0.0)); + let target = open(OPEN, 0.5); for (index, (before, length)) in cases.iter().enumerate() { let mut predecessor = Send::new(before, OPEN); for block in 0..20_u64 { predecessor.run(block * 64); } let kept = predecessor.route.ramps.ramps[1]; - let mut successor = - Send::continuing(&after, OPEN, [true, false, false, false, false, false]); + let mut successor = Send::new(&after, OPEN); swap(&mut successor, &mut predecessor); successor.run(20 * 64); let ramp = successor.route.ramps.ramps[0]; @@ -20723,225 +19868,49 @@ mod send_automation_tests { } } - /// B19-R7 (a removed row with a live move in flight): the `ll` row is removed at a swap - /// (1,280) while a hand offset (+3 dB over 2,000 from 1,152) runs on both columns. Column 0 - /// lands at once on the static `ll` over `SendMatrix` (960), cutting the move there (the - /// control plane flags that rebuild `TransitionFallback`); column 1, which lost nothing, keeps - /// its move. The second case removes the column's last curve (`ll`, `rr` kept): it lands the - /// same way on its cell's words. + /// B19-R7 (a removed curve with a live move in flight): the left lane's own mute curve, which + /// holds the left column shut, is removed at a swap (1,280) while a live send move (gain 1.0 + /// -> 0.5 over 2,000 from 1,152) runs on the right column. Column 0 lands at once on the moved + /// cell's words over the mute length (200), a column jump that would cut a move in flight + /// there (the control plane flags such a rebuild `TransitionFallback`); column 1, which lost + /// nothing, keeps its move. An open curve's removal composes the adopted target and moves + /// nothing. /// /// Red if the removal never lands, or lands late (an owed landing), or touches column 1. #[test] - fn a_removed_send_row_lands_at_once_across_a_move_in_flight() { - let cases = [ - ( - Curves { - gain: Some(flat(-6.0)), - matrix: [Some(flat(0.5)), None, None, None], - ..Curves::default() - }, - Curves { - gain: Some(flat(-6.0)), - ..Curves::default() - }, - OPEN, - RouteTransform { gain: 3.0, ..OPEN }, - [true, false, false, false, false, false], - open(OPEN, law(-6.0, 3.0)), - ), - ( - Curves { - matrix: [Some(flat(0.5)), None, None, Some(flat(0.5))], - ..Curves::default() - }, - Curves { - matrix: [None, None, None, Some(flat(0.5))], - ..Curves::default() - }, - RouteTransform { gain: 1.0, ..OPEN }, - RouteTransform { gain: 0.5, ..OPEN }, - [false, false, false, false, false, true], - open(OPEN, 0.5), - ), - ]; - for (case, (before, after, base, moved, continued, landed)) in cases.iter().enumerate() { - let mut predecessor = Send::new(before, *base); - for index in 0..18_u64 { - predecessor.run(index * 64); - } - predecessor.write(*moved, 2_000); - for index in 18..20_u64 { - predecessor.run(index * 64); - } - let record = predecessor.route.ramps.ramps; - assert_eq!( - record[0].length, 2_000, - "case {case}: the move is in flight" - ); - let mut successor = Send::continuing(after, *moved, *continued); - swap(&mut successor, &mut predecessor); - successor.run(20 * 64); - let ramp = successor.route.ramps.ramps[0]; - assert!( - aims(&ramp, *landed, 960, 0), - "case {case}: {:?} over {}", - ramp.target, - ramp.length - ); - assert_eq!(successor.route.ramps.positions[0], 64, "case {case}"); - for index in 21..49_u64 { - successor.run(index * 64); - assert_eq!( - successor.route.ramps.ramps[1], record[1], - "case {case}, block {index}: column 1's move runs on" - ); - assert_eq!( - successor.route.ramps.ramps[0], ramp, - "case {case}, block {index}" - ); - } - } - } - - /// One block of [`QUANTUM`] samples from render sample `first` read through `driver`. - fn run_seeking(send: &mut Send, first: u64, driver: &Seeking) { - let (mut left, mut right) = input(QUANTUM); - let time = GraphBlockTime::new(Some(first), QUANTUM as u32, Some(LENGTHS), Some(driver)); - if send - .route - .begin_block(send.gate.snapshot(), &time) - .is_some() - { - send.route.mix(&mut left, &mut right); - } - } - - /// B19-R7 (no stale value survives): the setups of the batch 19 verifier's probes. A send - /// loses `ll` at a swap (1,280) with a hand move in flight, in two forms (the column's last - /// curve, `rr` kept; `ll` beside a riding gain). Then either nothing, a further rebuild of - /// the same plan at 1,600, or a seek (identity at 1,920 and at 3,136, forward 1,920 -> 5,000, - /// back 1,920 -> 256). At block 120 every column aims at the fresh plan's target (a plan that - /// never heard `ll`, bound with the moved values, through the same timeline), bit for bit. - /// - /// Red if a landing is dropped by a seek or by the second rebuild and leaves the removed - /// row's value. - #[test] - fn a_removed_send_row_leaves_no_stale_value_after_a_seek_or_a_further_rebuild() { - let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; - let forms = [ - ( - Curves { - matrix: [Some(flat(0.5)), None, None, Some(flat(0.5))], - ..Curves::default() - }, - Curves { - matrix: [None, None, None, Some(flat(0.5))], - ..Curves::default() - }, - RouteTransform { gain: 1.0, ..OPEN }, - RouteTransform { gain: 0.5, ..OPEN }, - [false, false, false, false, false, true], - ), - ( - Curves { - gain: Some(ride.clone()), - matrix: [Some(flat(0.5)), None, None, None], - ..Curves::default() - }, - Curves { - gain: Some(ride), - ..Curves::default() - }, - OPEN, - RouteTransform { gain: 3.0, ..OPEN }, - [true, false, false, false, false, false], - ), - ]; - // (seek at, seek to, a second rebuild at block 25) - let scenarios = [ - (u64::MAX, 0_i64, false), - (u64::MAX, 0, true), - (1_920, 1_920, false), - (3_136, 3_136, false), - (1_920, 5_000, false), - (1_920, 256, false), - (1_920, 256, true), - ]; - for (form, (before, after, base, moved, continued)) in forms.iter().enumerate() { - for &(at, to, rebuild) in &scenarios { - let driver = Seeking { at, to }; - let mut fresh = Send::new(after, *moved); - let mut predecessor = Send::new(before, *base); - for index in 0..20_u64 { - if index == 18 { - predecessor.write(*moved, 2_000); - } - run_seeking(&mut predecessor, index * 64, &driver); - run_seeking(&mut fresh, index * 64, &driver); - } - let mut successor = Send::continuing(after, *moved, *continued); - swap(&mut successor, &mut predecessor); - for index in 20..120_u64 { - if rebuild && index == 25 { - let mut next = Send::continuing(after, *moved, *continued); - swap(&mut next, &mut successor); - successor = next; - } - run_seeking(&mut successor, index * 64, &driver); - run_seeking(&mut fresh, index * 64, &driver); - } - for column in 0..2 { - let want = fresh.route.ramps.ramps[column].target; - let got = successor.route.ramps.ramps[column]; - assert!( - aims(&got, want, got.length, column), - "form {form}, seek {at} -> {to}, rebuild {rebuild}, column {column}: \ - {:?} != {want:?}", - got.target - ); - } - } - } - } - - /// U2 fold 1, MAJOR-4 (K1: a value jump keeps its length, `with_held_grid`): `ll` steps at - /// 1,000 and jumps over `SendMatrix` (960, to 1,959) while the gain rides. The gain's grid - /// events whose ramps end inside the jump are held; the first whose ramp ends after it - /// (1,920) retargets the column again over the grid ramp. - /// - /// Red if the gain's grid at 1,024 cuts the matrix step's jump. - #[test] - fn a_matrix_step_keeps_its_jump_length_under_a_gain_ride() { - let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; - let curves = Curves { - gain: Some(ride.clone()), - matrix: [ - Some(vec![ - seg(0, 1_000, 0.9, 0.9, Shape::Step), - seg(1_000, 9_000, 0.3, 0.3, Shape::Step), - ]), - None, - None, - None, - ], + fn a_removed_follow_curve_lands_at_once_across_a_move_in_flight() { + let before = Curves { + source: [Some(flat(1.0)), Some(flat(0.0))], ..Curves::default() }; - let mut send = Send::new(&curves, OPEN); - let by_block = ramps_by_block(&mut send, 32); - let step = by_block[1_000 / QUANTUM][0]; - assert_eq!(step.length, 960, "the step jumps over its own length"); - for block in 1_024 / QUANTUM..1_920 / QUANTUM { - assert_eq!(by_block[block][0], step, "block {block}: the jump runs"); - } - let grid = by_block[1_920 / QUANTUM][0]; - let target = open( - RouteTransform { ll: 0.3, ..OPEN }, - law(automation::value_at(&ride, 1_920 + 63), 0.0), - ); + let after = Curves { + source: [None, Some(flat(0.0))], + ..Curves::default() + }; + let base = RouteTransform { gain: 1.0, ..OPEN }; + let moved = RouteTransform { gain: 0.5, ..OPEN }; + let mut predecessor = moving(&before, base, moved); + let record = predecessor.route.ramps.ramps; + assert_eq!(record[1].length, 2_000, "the move is in flight"); + let mut successor = Send::new(&after, moved); + swap(&mut successor, &mut predecessor); + successor.run(20 * 64); + let ramp = successor.route.ramps.ramps[0]; assert!( - aims(&grid, target, 64, 0), - "the grid moves the column again" + aims(&ramp, open(OPEN, 0.5), 200, 0), + "{:?} over {}", + ramp.target, + ramp.length ); + assert_eq!(successor.route.ramps.positions[0], 64); + for index in 21..49_u64 { + successor.run(index * 64); + assert_eq!( + successor.route.ramps.ramps[1], record[1], + "block {index}: column 1's move runs on" + ); + assert_eq!(successor.route.ramps.ramps[0], ramp, "block {index}"); + } } /// A timeline that seeks at render sample `at` (a block's first sample) to `to`: identity @@ -20984,58 +19953,73 @@ mod send_automation_tests { } } - /// U2 fold 1 (reviewer's missing test): a seek landing inside a column jump. The send's own - /// mute closes at 1,000 over the mute length (to 1,200) and holds the riding gain; a seek at - /// render sample 1,088 back to timeline 256 (open) lands every curve there exactly, and the - /// landing ends the hold: from the next block the gain's grid moves the columns again. + /// One block of [`QUANTUM`] samples from render sample `first` read through `driver`. + fn run_seeking(send: &mut Send, first: u64, driver: &Seeking) { + let (mut left, mut right) = input(QUANTUM); + send.block_with(first, LENGTHS, driver, &mut left, &mut right); + } + + /// B19-R7 (no stale value survives): a send loses its left lane's own mute curve at a swap + /// (1,280) with a live send move in flight. Then either nothing, a further rebuild of the same + /// plan at 1,600, or a seek (identity at 1,920 and at 3,136, forward 1,920 -> 5,000, back + /// 1,920 -> 256). At block 120 every column aims at the fresh plan's target (a plan that never + /// heard the curve, bound with the moved values, through the same timeline), bit for bit. /// - /// Red if a cursor's deferral or the output's column hold outlives a seek landing (the grid - /// would stay held until timeline 1,200). + /// Red if a landing is dropped by a seek or by the second rebuild and leaves the removed + /// curve's value. #[test] - fn a_seek_landing_inside_a_column_jump_ends_the_hold() { - let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; - let curves = Curves { - gain: Some(ride.clone()), - mute: Some(step(1_000, 0.0, 1.0)), + fn a_removed_follow_curve_leaves_no_stale_value_after_a_seek_or_a_further_rebuild() { + let before = Curves { + source: [Some(step(1_000, 0.0, 1.0)), Some(flat(0.0))], ..Curves::default() }; - let mut send = Send::new(&curves, OPEN); - let driver = Seeking { at: 1_088, to: 256 }; - let mut ramps = Vec::new(); - for index in 0..20_u64 { - let (mut left, mut right) = input(QUANTUM); - let time = GraphBlockTime::new( - Some(index * QUANTUM as u64), - QUANTUM as u32, - Some(LENGTHS), - Some(&driver), - ); - if send - .route - .begin_block(send.gate.snapshot(), &time) - .is_some() - { - send.route.mix(&mut left, &mut right); + let after = Curves { + source: [None, Some(flat(0.0))], + ..Curves::default() + }; + let base = RouteTransform { gain: 1.0, ..OPEN }; + let moved = RouteTransform { gain: 0.5, ..OPEN }; + // (seek at, seek to, a second rebuild at block 25) + let scenarios = [ + (u64::MAX, 0_i64, false), + (u64::MAX, 0, true), + (1_920, 1_920, false), + (3_136, 3_136, false), + (1_920, 5_000, false), + (1_920, 256, false), + (1_920, 256, true), + ]; + for &(at, to, rebuild) in &scenarios { + let driver = Seeking { at, to }; + let mut fresh = Send::new(&after, moved); + let mut predecessor = Send::new(&before, base); + for index in 0..20_u64 { + if index == 18 { + predecessor.write(moved, 2_000); + } + run_seeking(&mut predecessor, index * 64, &driver); + run_seeking(&mut fresh, index * 64, &driver); + } + let mut successor = Send::new(&after, moved); + swap(&mut successor, &mut predecessor); + for index in 20..120_u64 { + if rebuild && index == 25 { + let mut next = Send::new(&after, moved); + swap(&mut next, &mut successor); + successor = next; + } + run_seeking(&mut successor, index * 64, &driver); + run_seeking(&mut fresh, index * 64, &driver); + } + for column in 0..2 { + let want = fresh.route.ramps.ramps[column].target; + let got = successor.route.ramps.ramps[column]; + assert!( + aims(&got, want, got.length, column), + "seek {at} -> {to}, rebuild {rebuild}, column {column}: {:?} != {want:?}", + got.target + ); } - ramps.push(send.route.ramps.ramps); - } - let curve = |t: i64| automation::value_at(&ride, t); - let landed = ramps[1_088 / QUANTUM]; - for column in 0..2 { - assert!( - aims(&landed[column], open(OPEN, law(curve(256), 0.0)), 0, column), - "the landing, column {column}: {:?} over {}", - landed[column].target, - landed[column].length - ); - } - // Block 18 reads timeline 320: its grid point retargets toward the curve at 383. - let grid = ramps[1_088 / QUANTUM + 1]; - for column in 0..2 { - assert!( - aims(&grid[column], open(OPEN, law(curve(383), 0.0)), 64, column), - "the grid after the landing, column {column}" - ); } } @@ -21049,10 +20033,8 @@ mod send_automation_tests { /// reopens the column the curve still closes. #[test] fn a_hand_mute_and_a_gate_fade_together() { - let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; let curves = Curves { source: [Some(step(1_000, 0.0, 1.0)), None], - gain: Some(ride), ..Curves::default() }; let mut send = Send::new(&curves, OPEN); @@ -21061,11 +20043,7 @@ mod send_automation_tests { mute: true, ..send.route.flags }; - send.revision += 1; - let record = FollowRouteControlRecord::new(OPEN, muted, 200).expect("record"); - send.producer - .write(send.pin.stamp(send.revision).expect("stamp"), record); - send.pin.publish(&send.gate, send.revision); + send.write_with(OPEN, muted, 200); ramps.extend((15..30).map(|index| { send.run(index * QUANTUM as u64); send.route.ramps.ramps @@ -21087,11 +20065,7 @@ mod send_automation_tests { mute: false, ..send.route.flags }; - send.revision += 1; - let record = FollowRouteControlRecord::new(OPEN, unmuted, 200).expect("record"); - send.producer - .write(send.pin.stamp(send.revision).expect("stamp"), record); - send.pin.publish(&send.gate, send.revision); + send.write_with(OPEN, unmuted, 200); send.run(30 * QUANTUM as u64); let reopened = send.route.ramps.ramps; assert!( @@ -21100,44 +20074,33 @@ mod send_automation_tests { .all(|&index| reopened[0].target[index].to_bits() == 0.0_f32.to_bits()), "the curve keeps the left column shut" ); - assert_eq!(reopened[1].length, 200, "the right column reopens"); assert!( - super::COLUMNS[1] - .iter() - .all(|&index| reopened[1].target[index] != 0.0), - "the right column reopens to the ride" + aims(&reopened[1], open(OPEN, 0.5), 200, 1), + "the right column reopens" ); } /// U2 fold 1 (reviewer's missing test): a source mute curve added at a swap (1,280) to a send - /// whose hand offset move (+3 dB over 2,000 from 1,152) is in flight, the curve closed there. - /// The added gate is a gate change: the left column fades to silence at once over the mute - /// length (R19-3), and the right column, which the curve does not reach, keeps the move. + /// whose live send move (gain 0.5 -> 1.0 over 2,000 from 1,152) is in flight, the curve + /// closed there. The added gate is a gate change: the left column fades to silence at once + /// over the mute length (R19-3), and the right column, which the curve does not reach, keeps + /// the move. /// /// Red if the added gate waits for the move's end, or if it cuts the move on the other column. #[test] fn a_source_mute_added_while_a_move_is_in_flight_fades_its_column_at_once() { let before = Curves { - gain: Some(flat(-6.0)), + source: [None, Some(flat(0.0))], ..Curves::default() }; let after = Curves { - source: [Some(flat(1.0)), None], - gain: Some(flat(-6.0)), + source: [Some(flat(1.0)), Some(flat(0.0))], ..Curves::default() }; - let moved = RouteTransform { gain: 3.0, ..OPEN }; - let mut predecessor = Send::new(&before, OPEN); - for index in 0..18_u64 { - predecessor.run(index * 64); - } - predecessor.write(moved, 2_000); - for index in 18..20_u64 { - predecessor.run(index * 64); - } + let moved = RouteTransform { gain: 1.0, ..OPEN }; + let mut predecessor = moving(&before, OPEN, moved); let record = predecessor.route.ramps.ramps; - let mut successor = - Send::continuing(&after, moved, [true, false, false, false, false, false]); + let mut successor = Send::new(&after, moved); swap(&mut successor, &mut predecessor); successor.run(20 * 64); let fade = successor.route.ramps.ramps[0]; @@ -21154,118 +20117,6 @@ mod send_automation_tests { ); } - /// U2 fold 2, MAJOR-B P2 (a removed row's landing length leaks to no later jump): the gain - /// rides and the route's mute curve closes at 3,160 and reopens at 3,260; `ll` is removed at - /// 1,280 with a +3 dB hand move (2,000 samples from 1,152) in flight, and the column lands at - /// once (B19-R7). From 3,456 every column ramp is the twin's that never heard `ll` (the gain - /// lands at 3,460 over `SendGain`, 480). - /// - /// Red if the removed row's length (960) stays on a cursor and stretches its later jumps. - #[test] - fn a_removed_row_landing_lengthens_no_later_jump() { - let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; - let mute = vec![ - seg(0, 3_160, 0.0, 1.0, Shape::Step), - seg(3_160, 3_260, 1.0, 0.0, Shape::Step), - ]; - let before = Curves { - gain: Some(ride.clone()), - mute: Some(mute.clone()), - matrix: [Some(flat(0.5)), None, None, None], - ..Curves::default() - }; - let after = Curves { - gain: Some(ride), - mute: Some(mute), - ..Curves::default() - }; - let moved = RouteTransform { gain: 3.0, ..OPEN }; - let run = |removed: bool| { - let mut predecessor = Send::new(if removed { &before } else { &after }, OPEN); - for index in 0..18_u64 { - predecessor.run(index * 64); - } - predecessor.write(moved, 2_000); - for index in 18..20_u64 { - predecessor.run(index * 64); - } - let mut successor = - Send::continuing(&after, moved, [true, true, false, false, false, false]); - swap(&mut successor, &mut predecessor); - (20..64_u64) - .map(|index| { - successor.run(index * 64); - successor.route.ramps.ramps - }) - .collect::>() - }; - let removed = run(true); - let twin = run(false); - assert_eq!( - twin[54 - 20][0].length, - 480, - "the twin's gain lands over SendGain" - ); - for (block, (removed, twin)) in removed.iter().zip(&twin).enumerate().skip(54 - 20) { - assert_eq!(removed, twin, "block {}", block + 20); - } - } - - /// U2 fold 2, MAJOR-B P3 (a gate never carries a removed row's length): column 0 hears a - /// source mute curve and `ll`; `ll` is removed at 1,280 with a hand move in flight, and the - /// successor's source mute closes at 2,400. The close fades over the mute length (200). - /// - /// Red if the removed row's `SendMatrix` length (960) is put on the gate cursor. - #[test] - fn a_source_mute_close_after_a_removed_row_fades_over_the_mute_length() { - let base = RouteTransform { gain: 1.0, ..OPEN }; - let moved = RouteTransform { gain: 0.5, ..OPEN }; - let before = Curves { - source: [Some(flat(0.0)), None], - matrix: [Some(flat(0.5)), None, None, Some(flat(0.5))], - ..Curves::default() - }; - let after = Curves { - source: [Some(step(2_400, 0.0, 1.0)), None], - matrix: [None, None, None, Some(flat(0.5))], - ..Curves::default() - }; - let mut predecessor = Send::new(&before, base); - for index in 0..18_u64 { - predecessor.run(index * 64); - } - predecessor.write(moved, 2_000); - for index in 18..20_u64 { - predecessor.run(index * 64); - } - let mut successor = - Send::continuing(&after, moved, [false, false, false, false, false, true]); - swap(&mut successor, &mut predecessor); - for index in 20..38_u64 { - successor.run(index * 64); - } - let fade = successor.route.ramps.ramps[0]; - assert!( - aims(&fade, [0.0; 4], 200, 0), - "{:?} over {}", - fade.target, - fade.length - ); - assert_eq!(successor.route.ramps.positions[0], 2_432 - 2_400); - } - - /// The coefficients of `transform` with linear gain `gain` whose follow term zeroes the - /// columns `zeroed`. - fn zeroed(transform: RouteTransform, gain: f32, zeroed: [bool; 2]) -> [f32; 4] { - gated_route_coefficients( - &RouteTransform { gain, ..transform }, - RouteGate { - mute: false, - follow_zeroed: zeroed, - }, - ) - } - /// #1655 D4 (V4): a send whose source's left lane only an automated VCA mute reaches (no own /// mute curve) closes its left column when the VCA mutes (0 -> 1 at 1,000), in the tap's node /// time: at render sample `1,000 + a(tap)`, over the `VcaMute` length (320), for `a(tap)` 0 @@ -21383,53 +20234,6 @@ mod send_automation_tests { } } - /// #1655 D4 with R19-3: a send's gain riding across a VCA unmute of both source lanes (1 -> 0 - /// at 1,000). The unmute retargets both columns at 1,000 over the `VcaMute` length (320) - /// toward the gain the route heard (the grid event at 960, toward 1,023); no gain grid event - /// inside the fade retargets either column, so the fade ends at exactly 1,320; there the gain - /// lands over its own length (480) on the curve at the landing's last frame (1,799). - /// - /// Red if a VCA mute cursor is not a gate of the column (the grid cuts its fade), if the held - /// move is not landed at its end, or if the fade takes the mute length. - #[test] - fn a_vca_unmute_fade_holds_the_columns_value_events_to_its_end() { - let ride = vec![seg(0, 8_000, 0.0, -24.0, Shape::Linear)]; - let curves = Curves { - gain: Some(ride.clone()), - vca: [vec![step(1_000, 1.0, 0.0)], vec![step(1_000, 1.0, 0.0)]], - ..Curves::default() - }; - let mut send = Send::new(&curves, OPEN); - let curve = |t: i64| automation::value_at(&ride, t); - let by_block = ramps_by_block(&mut send, 34); - let fade = by_block[1_000 / QUANTUM]; - let heard = open(OPEN, law(curve(1_023), 0.0)); - for column in 0..2 { - assert!( - aims(&fade[column], heard, VCA_MUTE, column), - "the fade, column {column}" - ); - } - for block in 1_000 / QUANTUM..1_320 / QUANTUM { - assert_eq!(by_block[block], fade, "block {block}: the fade runs whole"); - } - let landing = by_block[1_320 / QUANTUM]; - let landed = open(OPEN, law(curve(1_320 + 479), 0.0)); - for column in 0..2 { - assert!( - aims(&landing[column], landed, 480, column), - "the landing, column {column}" - ); - let ended = fade[column].coefficients_at(VCA_MUTE); - assert!( - super::COLUMNS[column] - .iter() - .all(|&index| landing[column].start[index].to_bits() == ended[index].to_bits()), - "the landing starts where the fade ended, column {column}" - ); - } - } - /// #1655 D6 (V4), the carry of a VCA mute cursor. A send whose left lane a VCA mute reaches /// (closing at 1,300, opening at 2,000) and whose right lane has its own mute and a VCA mute, /// carried at 1,344 inside the left column's fade, renders the never-swapped twin's bits @@ -21459,12 +20263,7 @@ mod send_automation_tests { for index in 0..21_u64 { assert_eq!(twin.run(index * 64), predecessor.run(index * 64)); } - let mut successor = Send::continuing_vca( - &curves(), - transform, - [false; GRAPH_ROUTE_ROWS], - [continued; 2], - ); + let mut successor = Send::continuing_vca(&curves(), transform, [continued; 2]); swap(&mut successor, &mut predecessor); for index in 21..60_u64 { assert_eq!( @@ -21492,7 +20291,6 @@ mod send_automation_tests { let lane = |own: Option, vca: Option| Curves { source: [own.map(flat), None], vca: [vca.map(flat).into_iter().collect(), Vec::new()], - gain: None, ..Curves::default() }; let cases = [ @@ -21521,15 +20319,16 @@ mod send_automation_tests { } // The VCA mute removed: the predecessor's left column closed by it, the successor's - // left column hearing only the send's `ll` ride (no follow curve). - let ll = Some(flat(0.9)); + // left column hearing no follow curve (the right lane's own mute keeps the send + // following). + let right = Some(flat(0.0)); let before = Curves { + source: [None, right.clone()], vca: [vec![flat(1.0)], Vec::new()], - matrix: [ll.clone(), None, None, None], ..Curves::default() }; let after = Curves { - matrix: [ll, None, None, None], + source: [None, right], ..Curves::default() }; let mut predecessor = Send::new(&before, transform); diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index 4fa2f9209..95f6555d3 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -57,7 +57,7 @@ use crate::prepare::{ EffectiveDiff, FaderEventLanes, 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_gain_offset, route_record_between, route_source_strip, same_params, strip_at, + route_record_between, route_source_strip, same_params, strip_at, }; use crate::prepare::{GraphJumpLengths, LiveRampRow, LiveRamps, MonitorBase, session_jump_lengths}; @@ -277,8 +277,8 @@ 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 (a strip's -/// fader and mute, builtin rows 5 and 6, a route's rows 1-6 and a VCA's `offset_db` and `mute`, -/// rows 1 and 2, [`controls::automation_row_renders`], #1651, #1654, #1655 D6) is added, +/// fader and mute, builtin rows 5 and 6, and a VCA's `offset_db` and `mute`, rows 1 and 2, +/// [`controls::automation_row_renders`], #1651, #1655 D6) is added, /// removed, retargeted or given other segments, or the `(member strip, lane)` set an automated /// VCA lane reaches differs (a membership or nesting edit of an automated VCA, #1655 D5: each /// new pair needs new event state). A plan compiles its cells from these entries, so only a @@ -1034,16 +1034,10 @@ fn lower_routes<'a>( // #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. - // #1654 D6: a route that automates a row of its own takes the seven-word record too, with - // its automated gain's hand offset in the `gain` word, never the six-word form. let following = match following_route_lanes(next.structure(), after) { Some(automated) => Some(Following { terms: diff.terms(before, after).ok_or(LiveRebuild::Structure)?, automated, - gain_offsets: [ - route_gain_offset(ModelView::whole(current), &before.id), - route_gain_offset(next, &after.id), - ], }), None => None, }; @@ -1432,12 +1426,8 @@ struct RouteTarget<'m, 'a> { struct Following { /// The source lanes' effective mute, in `[before, after]` (OQ1 = C: it holds no curve). terms: [[bool; 2]; 2], - /// The source lanes and route rows the next plan automates (flags bits 4-11, #1654 D2). - automated: graph::RouteAutomatedSet, - /// The hand offset of the route's `gain_db`, in `[before, after]` (#1654 R17-6): the `gain` - /// word when the plan automates the gain. `after` is the view's, so an in-place edit of the - /// driving entry's offset (R17-1) reaches the record. - gain_offsets: [f32; 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. @@ -1553,7 +1543,6 @@ fn route_records<'a>( target.after, following.terms, following.automated, - following.gain_offsets, target.gates, target.gate_length, target.edit, diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 8a9ce38ef..a3a081541 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -594,10 +594,10 @@ pub fn follow_route_producer_position( } /// The position of the seven-word producer that may take `record` for route `id`: the producer -/// of [`follow_route_producer_position`], when the record carries the automated set its plan bound -/// (flags bits 4-11, [`graph::RouteFollowFlags::automated_set`], #1653 D5, #1654 D2). Render takes -/// that set from its own cursors, so a record whose set differs is an internal fault: the commit -/// refuses it before any write, and no ack precedes a drop. Allocation-free. +/// of [`follow_route_producer_position`], when the record carries the automated lanes its plan +/// bound (flags bits 4-5, #1653 D5). Render takes them from its own cursors, so a record whose +/// bits differ is an internal fault: the commit refuses it before any write, and no ack precedes +/// a drop. Allocation-free. #[must_use] pub fn follow_route_producer_for( producers: &[graph::GraphFollowRouteControlProducer], @@ -605,7 +605,7 @@ pub fn follow_route_producer_for( record: &graph::FollowRouteControlRecord, ) -> Option { follow_route_producer_position(producers, id) - .filter(|&index| producers[index].automated() == record.flags().automated_set()) + .filter(|&index| producers[index].automated() == record.flags().automated) } /// The position of strip `id` in the canonical strip order: the tracks, then the submixes, each @@ -755,11 +755,11 @@ impl PlanStateInventory { /// Whether this plan's send join carries a following route of the plan it was prepared to /// displace into a following route of its own that no longer hears one of the predecessor's - /// curves: a route row (`gain_db`, `mute`, a matrix entry) or a source lane's mute curve - /// (#1654, root ruling B19-R7). `false` for a plan prepared without a predecessor and for a + /// curves: a source lane's mute curve, its own or a reaching VCA's (#1654, root ruling + /// B19-R7). `false` for a plan prepared without a predecessor and for a /// declared discontinuity, which carries nothing. /// - /// Render lands such a column at once over the removed row's own length at the swap; the + /// Render lands such a column at once over the removed curve's own length at the swap; the /// control plane folds that landing into its send-move window. The transition fallback reads /// the wider [`Self::carries_a_following_route_into_a_following_route`] (B19-R8). #[must_use] @@ -773,8 +773,8 @@ impl PlanStateInventory { /// discontinuity, which carries nothing. /// /// **Conservative (B19-R8; TBD54 removes it).** Render may land a carried column at once at - /// the swap (a removed row or source mute curve, or a column that composes with no placed - /// value), so a live send move still ramping on it is cut. The control plane completes such a + /// the swap (a removed follow curve, or a column that composes otherwise), so a live send + /// move still ramping on it is cut. The control plane completes such a /// rebuild as [`engine::realtime::CandidateOutcome::TransitionFallback`] while a live send move /// may still ramp. It over-reports: the join cannot tell which column lands at once or whether /// that column moves at the swap. It includes @@ -2943,9 +2943,6 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( // 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. - // #1654 D2: a route that automates one of its own rows 1-6 is a following route too, with its - // route cells and flags bits 6-11, and its automated gain's hand offset seeded in the `gain` - // word. let mut follow_cells = Vec::new(); for route in &model.routes { let Some(automated) = following_route_lanes(model, route) else { @@ -2954,29 +2951,18 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( let Some(source) = route_source_strip(model, route) else { return Err(graph_failure("host.route_control.attach")); }; - // `following_route_lanes` and the builtins plan read the same rendered mute and route - // entries, so a following route always has the cells its set names, in the plan's one - // program. - let mute_cells = builtins.strip_mute_cells(route_source_id(route).as_str()); - let route_cells = builtins.route_cells(route.id.as_str()); - let (mut program, cells, rows) = match (mute_cells, route_cells) { - (Some((program, cells)), Some((other, rows))) - if std::sync::Arc::ptr_eq(&program, &other) => - { - (Some(program), cells, rows) - } - (Some((program, cells)), None) => { - (Some(program), cells, [None; graph::GRAPH_ROUTE_ROWS]) - } - (None, Some((program, rows))) => (Some(program), [None; 2], rows), - // #1655 D4: a send whose source lanes only VCA mutes reach takes the VCA cells' - // program below. - (None, None) => (None, [None; 2], [None; graph::GRAPH_ROUTE_ROWS]), - _ => return Err(graph_failure("host.route_control.attach")), + // `following_route_lanes` and the builtins plan read the same rendered mute entries, so + // a following route always has the cells its lanes name, in the plan's one program. + // #1655 D4: a send whose source lanes only VCA mutes reach takes the VCA cells' program + // below. + let (mut program, cells) = match builtins.strip_mute_cells(route_source_id(route).as_str()) + { + Some((program, cells)) => (Some(program), cells), + None => (None, [None; 2]), }; // #1655 D4: a source lane's curve is its own mute cell, then the reaching automated VCA - // mute cells in ascending VCA ID; `automated.lanes` names the lanes that have one. The - // route op ORs the VCA mute cursors into the lane's follow term at `a(tap)`. + // mute cells in ascending VCA ID; `automated` names the lanes that have one. The route op + // ORs the VCA mute cursors into the lane's follow term at `a(tap)`. let vca_mutes = follow_vca_mute_cells( |vca| builtins.vca_cells(vca), model, @@ -2988,16 +2974,10 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( return Err(graph_failure("host.route_control.attach")); }; let curves = [0, 1].map(|lane| cells[lane].is_some() || !vca_mutes[lane].is_empty()); - if curves != automated.lanes || rows.map(|cell| cell.is_some()) != automated.rows { + if curves != automated { return Err(graph_failure("host.route_control.attach")); } let flags = follow_route_flags(route, effective.strips[source].mute, automated); - let offset = route_gain_offset(controls::ModelView::whole(model), &route.id); - // #1654 D3: the rows whose table the predecessor's model holds unchanged carry their - // cursors verbatim at the swap (the source's continued rows, which the route op reads). - let continued = successor.map_or([false; graph::GRAPH_ROUTE_ROWS], |base| { - controls::continued_route_rows(base.committed, model, &route.id) - }); // #1655 D6: a source lane whose own and reaching VCA mute tables continue carries its // VCA mute cursors verbatim. let continued_vca = successor.map_or([false; 2], |base| { @@ -3005,15 +2985,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( }); follow_cells.push(( route.id.as_str(), - ( - program, - cells, - vca_mutes, - rows, - flags, - offset, - (continued, continued_vca), - ), + (program, cells, vca_mutes, flags, continued_vca), )); } let mut artifact = GraphCompiler::compile_with_builtins(GraphBuiltinsCompileRequest { @@ -3069,28 +3041,20 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( let mut sources: BTreeMap<&str, _> = follow_cells.into_iter().collect(); artifact .attach_route_live_controls_following(&live_superseded, six_word, |route_id| { - let (program, cells, vca_mutes, rows, flags, offset, (continued, continued_vca)) = + let (program, cells, vca_mutes, flags, continued_vca) = sources.remove(route_id.as_str())?; let node = GraphNodeId::Route { route_id: route_id.clone(), }; - let source = graph::GraphFollowRouteSource::new( + graph::GraphFollowRouteSource::new( program, cells, vca_mutes, - rows, graph::floored_input_arrival(&arrivals, &node), follow_route_jumps, - graph_compiler::route_gain_law, flags, - )? - .with_continued_rows(continued) - .with_continued_vca_mutes(continued_vca); - if flags.automated_gain { - source.with_gain_offset(offset) - } else { - Some(source) - } + ) + .map(|source| source.with_continued_vca_mutes(continued_vca)) }) .map_err(|error| match error { graph::GraphRouteControlError::AlreadyAttached @@ -4539,52 +4503,41 @@ pub(crate) fn follow_vca_mute_cells

( Some(lanes) } -/// The flags word of a following route (#1652 D1, #1654 D2): the route's `mute` and -/// `follows_mute`, the source lanes' `terms` and the plan's automated set (bits 4-11). -pub(crate) fn follow_route_flags( +/// The flags word of a following route (#1652 D1): the route's `mute` and `follows_mute`, the +/// source lanes' `terms` and the plan's `automated` lanes (bits 4-5). +pub(crate) const fn follow_route_flags( route: &session::Route, terms: [bool; 2], - automated: graph::RouteAutomatedSet, + automated: [bool; 2], ) -> graph::RouteFollowFlags { - let [automated_gain, automated_mute, ll, lr, rl, rr] = automated.rows; graph::RouteFollowFlags { mute: route.mute, follows_mute: route.follows_mute, terms, - automated: automated.lanes, - automated_gain, - automated_mute, - automated_matrix: [ll, lr, rl, rr], + automated, } } /// 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 -/// ([`follow_route_flags`]) and `length`. When the plan automates the route's `gain_db` (bit 6), -/// the `gain` word is the hand offset `gain_offset` in dB instead of the static gain, which is the -/// fallback and is not heard (#1654, batch 10 amendment); an automated matrix entry's word is its -/// static fallback, which render ignores. +/// ([`follow_route_flags`]) and `length`. /// /// # Errors /// -/// [`LiveRebuild::Domain`] when the gain or matrix is outside the route domain, the offset is not -/// finite, or `length` is longer than the cell's maximum. +/// [`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: graph::RouteAutomatedSet, - gain_offset: f32, + automated: [bool; 2], length: u32, ) -> Result { let matrix = &route.channel_matrix; - let mut transform = + let transform = graph_compiler::route_values(route.gain_db, [matrix.ll, matrix.lr, matrix.rl, matrix.rr]) .map_err(|_| LiveRebuild::Domain)?; let flags = follow_route_flags(route, terms, automated); - if flags.automated_gain { - transform.gain = gain_offset; - } graph::FollowRouteControlRecord::new(transform, flags, length).ok_or(LiveRebuild::Domain) } @@ -4594,20 +4547,15 @@ pub(crate) fn follow_route_record( /// 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; `gain_offsets` is the -/// hand offset of the route's `gain_db` per model, in `[before, after]` ([`route_gain_offset`]), -/// read only when the plan automates the gain. A record is built only when a word render hears -/// changes: the `gain` word (the offset when the gain is automated, else the static gain), an -/// un-automated matrix entry, `mute`, `follows_mute` or `terms`. A word of an automated matrix row -/// is the static fallback, which render ignores, so a change of only such words (or of the static -/// gain under an automated gain) gives no record and commits `model_only`: automation wins (#1654 -/// D4). `automated` is the plan's, not the model's, so a route that only gains or loses its curve +/// 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` (the -/// offset under an automated gain, R17-6) and `SendMatrix` lengths, else the `SendMute` length. +/// curve can make that a change of target), else the longest of the changed `SendGain` and +/// `SendMatrix` lengths, else the `SendMute` length. /// /// # Errors /// @@ -4618,18 +4566,14 @@ pub(crate) fn follow_route_record_between( before: &session::Route, after: &session::Route, terms: [[bool; 2]; 2], - automated: graph::RouteAutomatedSet, - gain_offsets: [f32; 2], + automated: [bool; 2], gates: [[bool; 2]; 2], gate_length: u32, edit: RouteEditRamps, ) -> Result, LiveRebuild> { - let prior = follow_route_record(before, terms[0], automated, gain_offsets[0], 0)?; - let current = follow_route_record(after, terms[1], automated, gain_offsets[1], 0)?; - // The words render hears: an automated matrix entry's word is masked (`None`). - let [_, _, ll, lr, rl, rr] = automated.rows; - let masked = [false, ll, lr, rl, rr]; - let heard_bits = |record: &graph::FollowRouteControlRecord| { + 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, @@ -4637,28 +4581,30 @@ pub(crate) fn follow_route_record_between( rl, rr, } = record.transform(); - let words = [gain, ll, lr, rl, rr]; - core::array::from_fn::<_, 5, _>(|word| (!masked[word]).then(|| words[word].to_bits())) + [gain, ll, lr, rl, rr].map(f32::to_bits) }; - let (prior_bits, current_bits) = (heard_bits(&prior), heard_bits(¤t)); - if prior_bits == current_bits && prior.flags() == current.flags() { + 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 = (prior_bits[0] != current_bits[0]) + let gain = (before.gain_db.to_bits() != after.gain_db.to_bits()) .then(|| ramps.resolve(LiveRampRow::SendGain, edit.gain)); - let matrix = (prior_bits[1..] != current_bits[1..]) + 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, gain_offsets[1], length).map(Some) + follow_route_record(after, terms[1], automated, length).map(Some) } /// A fader or pan value outside the domain its render-side setter accepts. @@ -5342,28 +5288,25 @@ fn route_source_id(route: &session::Route) -> &session::StableId { } /// Whether a plan of `model` gives `route` the seven-word cell of a following route (#1652 D1, -/// #1654 D2, #1655 D4): it goes into a submix and either a lane of its source strip is a mute -/// event lane ([`controls::mute_event_lanes`]: its own mute is automated, the entries the builtins -/// plan compiles mute cells from, or an automated VCA `mute` lane reaches it) or the route -/// automates one of its own rows 1-6 ([`controls::route_automation`], the entries the builtins -/// plan compiles route cells from). Every other route keeps the six-word cell. A route into the -/// output never follows. +/// #1655 D4): it goes into a submix and a lane of its source strip is a mute event lane +/// ([`controls::mute_event_lanes`]: its own mute is automated, the entries the builtins plan +/// compiles mute cells from, or an automated VCA `mute` lane reaches it). 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 { following_route_lanes(model, route).is_some() } -/// What a plan of `model` automates of `route` (flags bits 4-11: the source lanes that have a mute -/// curve, own or a reaching automated VCA's, and the route rows 1-6 that have a cell), when that -/// plan gives `route` the seven-word cell of a following route ([`follows_automated_mute`]), else -/// `None`: the automated set of the route's records (#1652 D1, #1653 D1, #1654 D2, #1655 D4). The -/// source's VCA reach is read only when an entry drives a VCA `mute` row and an own mute lane is -/// not automated. +/// The source lanes whose mute a plan of `model` automates for `route` (own, or a reaching +/// automated VCA's), 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, #1655 D4). The source's VCA reach is read only when an entry drives a VCA `mute` +/// row and an own mute lane is not automated. #[must_use] pub(crate) fn following_route_lanes( model: &SessionModel, route: &session::Route, -) -> Option { +) -> Option<[bool; 2]> { if !matches!( route.destination, session::RouteDestination::SubmixInput { .. } @@ -5383,30 +5326,16 @@ pub(crate) fn following_route_lanes( } else { controls::mute_event_lanes(model, source, &model.vca_reach_of(source)) }; - let set = graph::RouteAutomatedSet { - lanes, - rows: controls::route_automation(model, &route.id).map(|entry| entry.is_some()), - }; - (set.lanes.contains(&true) || set.rows.contains(&true)).then_some(set) + lanes.contains(&true).then_some(lanes) } -/// The hand offset in dB of route `route`'s automated `gain_db` (row 1) as `view` holds it (#1654 -/// R17-6): the driving entry's `offset.left_db` (a shared row's two fields are equal, the -/// validator's `control.automation.offset`), or `+0.0` when no entry drives the row. A following -/// route's cell holds it in its `gain` word when the plan automates the gain. -#[must_use] -pub(crate) fn route_gain_offset(view: controls::ModelView<'_>, route: &session::StableId) -> f32 { - view.route_automation(route)[0].map_or(0.0, |entry| entry.offset.left_db) -} - -/// The jump lengths of a following route's cursor rows (#1652 D2, #1652b, #1654 D1): the row +/// The jump lengths of a following route's cursor rows (#1652 D2, #1652b, #1655 D4): the row /// rules over the block's session lengths, the rules the strip's own stages resolve -/// ([`LiveRamps::for_row`] of [`LiveRampRow::Mute`] for a source lane's and the route's own mute, -/// of [`LiveRampRow::SendGain`] for the gain and of [`LiveRampRow::SendMatrix`] for a matrix -/// entry), so the strip ramp and the send ramp have one length. Called on render (through the -/// route's `GraphRouteJumpRule`): `const fn` calls, struct builds and `match` reads, -/// allocation-free and panic-free. -// REALTIME_POLICY_BEGIN: the route rows' jump rule a following route calls once per block. +/// ([`LiveRamps::for_row`] of [`LiveRampRow::Mute`] for a source lane's own mute and of +/// [`LiveRampRow::VcaMute`] for a reaching VCA's mute), so the strip ramp and the send ramp have +/// one length. Called on render (through the route's `GraphRouteJumpRule`): `const fn` calls, a +/// struct build and `match` reads, allocation-free and panic-free. +// REALTIME_POLICY_BEGIN: the follow curves' jump rule a following route calls once per block. fn follow_route_jumps(lengths: GraphJumpLengths) -> graph::GraphRouteJumpLengths { let ramps = LiveRamps::from_lengths( lengths.fader_samples, @@ -5416,8 +5345,6 @@ fn follow_route_jumps(lengths: GraphJumpLengths) -> graph::GraphRouteJumpLengths graph::GraphRouteJumpLengths { mute: ramps.for_row(LiveRampRow::Mute), vca_mute: ramps.for_row(LiveRampRow::VcaMute), - gain: ramps.for_row(LiveRampRow::SendGain), - matrix: ramps.for_row(LiveRampRow::SendMatrix), } } // REALTIME_POLICY_END @@ -6192,10 +6119,6 @@ fn join_routes<'a>( after, diff.strip_mutes(source.0, source.1), automated, - [ - route_gain_offset(controls::ModelView::whole(committed), &before.id), - route_gain_offset(controls::ModelView::whole(model), &after.id), - ], gates, gate_length, edit, @@ -6231,18 +6154,18 @@ struct RouteCarry<'a> { /// [`PlanStateInventory::carries_a_following_route_into_a_following_route`]). follow_into_following: bool, /// Whether some move carries a following route into a following route that no longer hears - /// a row or source mute curve of the predecessor's (B19-R7, + /// a source mute curve of the predecessor's (B19-R7, /// [`PlanStateInventory::carries_a_following_route_that_loses_a_curve`]). follow_loses_a_curve: bool, /// The longest ramp of the following-route records among `retargets` (B19-R7). follow_retarget_ramp: u32, } -/// Whether a following route of automated set `before` loses a curve in one of `after`: a source -/// lane's mute or a route row that `before` automates and `after` does not (B19-R7). -fn loses_a_curve(before: graph::RouteAutomatedSet, after: graph::RouteAutomatedSet) -> bool { - let lost = |had: &[bool], has: &[bool]| had.iter().zip(has).any(|(&had, &has)| had && !has); - lost(&before.lanes, &after.lanes) || lost(&before.rows, &after.rows) +/// Whether a following route of automated lanes `before` loses a curve in `after`: a source +/// lane's mute curve (own or a reaching VCA's) that `before` automates and `after` does not +/// (B19-R7). +fn loses_a_curve(before: [bool; 2], after: [bool; 2]) -> bool { + before.iter().zip(&after).any(|(&had, &has)| had && !has) } /// The route half of the send join (#1284 D2): a send carries when both plans hold a live route of @@ -6277,8 +6200,8 @@ fn resolve_route_carry<'a>( // flight, so it flags every such carry: the conservative rule. carry.follow_into_six_word |= follows && !successor_follows; // B19-R7 (TBD54 deletes it): a carried following route that stays seven-word and loses - // a route row or a source mute curve lands that column at once over the removed row's - // length, cutting a live send move in flight there. The join flags every such carry; + // a source mute curve lands that column at once over the removed curve's length, cutting + // a live send move in flight there. The join flags every such carry; // the control plane completes it as the fallback while a send move may still ramp. carry.follow_loses_a_curve |= follows && successor_follows && loses_a_curve(heard, hears); // B19-R8 (TBD54 deletes it): any carried following route that stays seven-word may land @@ -7038,7 +6961,7 @@ mod tests { route_id: route.into(), unit, following: false, - automated: graph::RouteAutomatedSet::default(), + automated: [false; 2], }; let before = [live("a-main", 3), live("b-main", 5)]; let own = [live("a-main", 4), live("b-main", 2), live("c-main", 9)]; @@ -7104,7 +7027,7 @@ mod tests { route_id: route.into(), unit, following, - automated: graph::RouteAutomatedSet::default(), + automated: [false; 2], }; let join = |id| RouteJoin { id, retarget: None }; // (case, predecessor following, successor following, flagged) @@ -7127,21 +7050,18 @@ mod tests { } /// B19-R7: the join flags a carry of a following route into a following route whose - /// automated set lacks a curve of the predecessor's (a route row or a source lane's mute), - /// and only then: an unchanged or a larger set, and a kind change, leave it clear. + /// automated lanes lack a source lane's mute curve of the predecessor's, and only then: an + /// unchanged or a larger set, and a kind change, leave it clear. /// - /// Test value: red if the flag compares the sets for equality (an added row would flag), reads - /// only the rows (a removed source mute curve would complete exact) or ignores the kinds. + /// Test value: red if the flag compares the sets for equality (an added curve would flag), or + /// ignores the kinds. #[test] fn the_join_flags_a_following_route_that_loses_a_curve() { use super::{RouteJoin, resolve_route_carry}; - let set = |lanes: [bool; 2], rows: [bool; graph::GRAPH_ROUTE_ROWS]| { - graph::RouteAutomatedSet { lanes, rows } - }; - let ll = set([false; 2], [false, false, true, false, false, false]); - let ll_rr = set([false; 2], [false, false, true, false, false, true]); - let lane = set([true, false], [false; graph::GRAPH_ROUTE_ROWS]); - let none = graph::RouteAutomatedSet::default(); + let left = [true, false]; + let right = [false, true]; + let both = [true, true]; + let none = [false; 2]; let live = |unit, following, automated| graph::GraphLiveRoute { route_id: "a-main".into(), unit, @@ -7150,26 +7070,35 @@ mod tests { }; // (case, predecessor, successor, flagged) for (case, before, after, flagged) in [ - ("same set", live(1, true, ll), live(2, true, ll), false), - ("adds a row", live(1, true, ll), live(2, true, ll_rr), false), - ("loses a row", live(1, true, ll_rr), live(2, true, ll), true), - ("swaps a row", live(1, true, ll), live(2, true, lane), true), + ("same set", live(1, true, left), live(2, true, left), false), + ( + "adds a lane", + live(1, true, left), + live(2, true, both), + false, + ), + ( + "loses a lane", + live(1, true, both), + live(2, true, left), + true, + ), ( - "loses a source mute curve", - live(1, true, lane), - live(2, true, ll), + "swaps a lane", + live(1, true, left), + live(2, true, right), true, ), ( "seven -> six", - live(1, true, ll), + live(1, true, left), live(2, false, none), false, ), ( "six -> seven", live(1, false, none), - live(2, true, ll), + live(2, true, left), false, ), ] { diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 9fd15e93a..1cbe967c9 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -5382,289 +5382,44 @@ fn a_following_routes_edits_write_its_seven_word_cell_only() { assert!(delta.is_empty(), "an identical rewrite writes nothing"); } -// ---- #1654 T4: a send's own automated rows ----------------------------------------------------- - -/// The position of [`with_bus`]'s send (the first track's route into `bus`). -fn send_of(model: &SessionModel) -> usize { - model - .routes - .iter() - .position(|route| matches!(route.destination, RouteDestination::SubmixInput { .. })) - .expect("the send into bus") -} - -/// One stored entry on route row `control` of route `route`, on `both`: a linear ride for the gain -/// (row 1, dB) and the matrix (rows 3-6), a step for the route's own mute (row 2). -fn send_entry(route: &StableId, control: u32) -> Automation { - let (shape, from, to, unit) = match control { - 1 => (AutomationShape::Linear, -6.0, -2.0, ParameterUnit::Db), - 2 => (AutomationShape::Step, 0.0, 1.0, ParameterUnit::Linear), - _ => (AutomationShape::Linear, 0.25, 0.5, ParameterUnit::Linear), - }; - Automation { - id: id(&format!("send-row-{control}")), - target: ControlAddress { - owner: Owner::Route(route.clone()), - section: Section::Own, - control, - lane: ParameterChannel::Both, - }, - segments: vec![AutomationSegment { - shape, - start_sample: 0, - end_sample: 4_096, - start_value: from, - end_value: to, - unit, - }], - offset: session::AutomationOffset::default(), - } -} - -/// [`with_bus`]`(false)` with distinct session lengths for the three send rows (mute 10 ms, gain -/// 5 ms, matrix 2.5 ms) and one stored entry on each route row of `rows` of its send: a route -/// whose source strip automates nothing. -fn with_send_rows(rows: &[u32]) -> SessionModel { - let mut model = with_bus(false); - model.control_smoothing = Some(ControlSmoothing { - mute_ms: 10.0, - fader_ms: 5.0, - pan_ms: 2.5, - }); - let send = model.routes[send_of(&model)].id.clone(); - for &control in rows { - model.automation.push(send_entry(&send, control)); - } - normalized(&model) -} - -/// Route row `control` of `model`'s send, on `both`. -fn send_row(model: &SessionModel, control: u32) -> ControlAddress { - ControlAddress { - owner: Owner::Route(model.routes[send_of(model)].id.clone()), - section: Section::Own, - control, - lane: ParameterChannel::Both, - } -} - -/// `[gain, ll, lr, rl, rr]` of a transform, as bits. -fn transform_words(transform: graph::RouteTransform) -> [u32; 5] { - [ - transform.gain, - transform.ll, - transform.lr, - transform.rl, - transform.rr, - ] - .map(f32::to_bits) -} - -/// #1654 D3 (T4). A send automation edit needs a rebuild: an entry added on route rows 1-6 of a -/// send, one given another segment table and one removed are each `LiveRebuild::Automation`. -/// -/// Test value: red if a route entry change is lowered as a live value (the plan's route cells are -/// compiled from those entries, so a live commit would ack a curve nobody hears). -#[test] -fn a_send_automation_edit_is_a_rebuild() { - let plain = with_send_rows(&[]); - for control in 1..=6 { - let added = edited(&plain, |next| { - let send = next.routes[send_of(next)].id.clone(); - next.automation.push(send_entry(&send, control)); - }); - assert_eq!( - classify_live_delta(&plain, &added, STEP, &EditRamps::new()).map(|_| ()), - Err(LiveRebuild::Automation), - "row {control} added" - ); - } - let automated = with_send_rows(&[1, 4]); - let moved = edited(&automated, |next| { - next.automation[0].segments[0].end_value = -3.0; - }); - let removed = edited(&automated, |next| { - next.automation.remove(1); - }); - for (label, next) in [("other segments", moved), ("removed", removed)] { - assert_eq!( - classify_live_delta(&automated, &next, STEP, &EditRamps::new()).map(|_| ()), - Err(LiveRebuild::Automation), - "{label}" - ); - } -} - -/// #1654 R17-6 and D4 (T4). On a send whose `gain_db` and `matrix_lr` stored entries render: -/// -/// - a hand move of the gain (the entry's offset to `+3`, what `SET_CONTROL` row 1 writes) gives -/// one seven-word record whose `gain` word is the offset `+3` dB (not a linear gain), whose flags -/// carry bits 6 and 9 and no source lane, over the `SendGain` length (and the edit's own length -/// when it gives one); -/// - a static edit of the automated `matrix_lr` (the fallback) gives no record: the delta is empty, -/// so the commit is `model_only`; -/// - a live edit of the un-automated `matrix_ll` and of the route's own mute (D6) each give one -/// seven-word record, never a folded one, over the `SendMatrix` and `SendMute` lengths, with the -/// offset still in the `gain` word. -/// -/// Every case gives the same delta through the fast path over the row it names. -/// -/// Test value: red if a hand gain move writes the static fallback or a linear gain into word 0, -/// if the offset reaches a matrix word, if a hand edit of an automated matrix row is acked live -/// (automation wins), or if an un-automated row of an automated send is written in the six-word -/// form. -#[test] -fn a_sends_hand_edits_lower_its_offset_and_its_unautomated_rows_only() { - let current = with_send_rows(&[1, 4]); - let send = send_of(¤t); - let ramps = LiveRamps::for_session(¤t); - let expected_flags = graph::RouteFollowFlags { - automated_gain: true, - automated_matrix: [false, true, false, false], - ..graph::RouteFollowFlags::default() - }; - - // The hand offset. - let next = edited(¤t, |next| { - next.automation[0].offset = session::AutomationOffset { - left_db: 3.0, - right_db: 3.0, - }; - }); - let delta = following_delta(¤t, &next, &[send_row(¤t, 1)]); - assert!(delta.strips.is_empty()); - let records = seven_word(&delta); - assert_eq!(records.len(), 1, "one seven-word record"); - let record = records[0].1; - assert_eq!(record.flags(), expected_flags, "bits 6 and 9 only"); - let matrix = &next.routes[send].channel_matrix; - let mut expected = graph_compiler::route_values( - next.routes[send].gain_db, - [matrix.ll, matrix.lr, matrix.rl, matrix.rr], - ) - .expect("in domain"); - expected.gain = 3.0; - assert_eq!( - transform_words(record.transform()), - transform_words(expected), - "word 0 is the offset in dB; the matrix words are the route's" - ); - assert_eq!( - record.length(), - ramps.resolve(LiveRampRow::SendGain, None), - "the SendGain length" - ); - let mut own = EditRamps::new(); - own.set( - &EditRampTarget::Route(next.routes[send].id.clone()), - EditRampRow::RouteGain, - Some(77), - ); - let with_own = classify_live_delta(¤t, &next, ramps, &own).expect("live"); - assert_eq!( - seven_word(&with_own)[0].1.length(), - 77, - "the edit's own length" - ); - - // The automated matrix row's fallback: automation wins. - let next = edited(¤t, |next| next.routes[send].channel_matrix.lr = 0.75); - let delta = following_delta(¤t, &next, &[send_row(¤t, 4)]); - assert!(delta.is_empty(), "no record: the commit is model_only"); - - // D6: the un-automated `matrix_ll` and the route's own mute. - for (label, control, row) in [ - ("matrix_ll", 3, LiveRampRow::SendMatrix), - ("mute", 2, LiveRampRow::SendMute), - ] { - let next = edited(¤t, |next| match control { - 3 => next.routes[send].channel_matrix.ll = 0.5, - _ => next.routes[send].mute = true, - }); - let delta = following_delta(¤t, &next, &[send_row(¤t, control)]); - let records = seven_word(&delta); - assert_eq!(records.len(), 1, "{label}: one seven-word record"); - let record = records[0].1; - assert_eq!( - record.flags(), - graph::RouteFollowFlags { - mute: control == 2, - ..expected_flags - }, - "{label}" - ); - let matrix = &next.routes[send].channel_matrix; - let mut expected = graph_compiler::route_values( - next.routes[send].gain_db, - [matrix.ll, matrix.lr, matrix.rl, matrix.rr], - ) - .expect("in domain"); - expected.gain = 0.0; - assert_eq!( - transform_words(record.transform()), - transform_words(expected), - "{label}: the new values, the offset in word 0" - ); - assert_eq!(record.length(), ramps.resolve(row, None), "{label}"); - } -} - -/// #1654 D2 (T4). A send with a rendered route entry and no source mute curve gets the seven-word -/// lane, its producer binds the plan's automated set (bits 6 and 9 here, no source lane), and a -/// send with no rendered entry keeps the six-word lane. `follow_route_producer_for` (the commit's -/// pre-write check) takes the classifier's record for that send and refuses one whose automated -/// set differs in any bit, leaving the cell unwritten. +/// #1653 D5 (the commit's pre-write check, [`host_core::follow_route_producer_for`]): a send whose +/// source strip automates its left mute binds the seven-word lane, its producer holds the plan's +/// automated lanes (`[true, false]`), and the check takes the classifier's record for that send +/// and refuses one whose automated lanes differ in either bit, leaving the cell unwritten. /// -/// Test value: red if a route-row-only send keeps the six-word lane (render cannot hear its -/// curve), if the attach seeds flags bits 6-11 that disagree with the plan's cells, or if the -/// pre-write check compares fewer bits than the whole set (a record whose route rows disagree -/// would be written and acked while render ignores it). +/// Test value: red if the pre-write check compares no lane bit or only one (a record whose lanes +/// disagree with the plan would be written and acked while render ignores them). #[cfg(feature = "test-support")] #[test] -fn a_route_row_send_binds_its_set_and_the_commit_refuses_another() { - let lanes = |model: &SessionModel| { - host_core::prepare_host_runtime_with_live_lanes( - &compile(model), - &caps(), - &HostLiveControlRequest::default(), - host_core::HostLiveLanes::ALL, - &host_core::LiveValuesSuperseded::default(), - ) - .unwrap_or_else(|failure| { - panic!("prepare: {}", String::from_utf8_lossy(failure.as_bytes())) - }) - .1 - }; - let plain = lanes(&with_send_rows(&[])); - assert!(plain.follow_route_controls.is_empty(), "no seven-word lane"); - - let current = with_send_rows(&[1, 4]); - let handles = lanes(¤t); +fn a_following_send_binds_its_lanes_and_the_commit_refuses_others() { + let current = with_automated_follow(true); + let (_, handles) = host_core::prepare_host_runtime_with_live_lanes( + &compile(¤t), + &caps(), + &HostLiveControlRequest::default(), + host_core::HostLiveLanes::ALL, + &host_core::LiveValuesSuperseded::default(), + ) + .unwrap_or_else(|failure| panic!("prepare: {}", String::from_utf8_lossy(failure.as_bytes()))); let producers = &handles.follow_route_controls; assert_eq!(producers.len(), 1, "the send's seven-word lane"); - let send_id = current.routes[send_of(¤t)].id.as_str(); + let send = current + .routes + .iter() + .position(|route| route.follows_mute) + .expect("the following send"); + let send_id = current.routes[send].id.as_str(); assert_eq!(&*producers[0].route_id, send_id); - assert_eq!( - producers[0].automated(), - graph::RouteAutomatedSet { - lanes: [false; 2], - rows: [true, false, false, true, false, false], - } - ); + assert_eq!(producers[0].automated(), [true, false]); - let next = edited(¤t, |next| { - next.automation[0].offset = session::AutomationOffset { - left_db: 3.0, - right_db: 3.0, - }; - }); - let delta = following_delta(¤t, &next, &[send_row(¤t, 1)]); + let next = edited(¤t, |next| next.routes[send].gain_db = -6.0); + let delta = following_delta(¤t, &next, &[]); let record = seven_word(&delta)[0].1; assert_eq!( host_core::follow_route_producer_for(producers, send_id, &record), Some(0) ); - for bit in 4..12 { + for bit in [4, 5] { let word = record.flags().to_word() ^ (1 << bit); let flags = graph::RouteFollowFlags::from_word(word).expect("a defined bit"); let other = diff --git a/crates/host-core/tests/route_mute.rs b/crates/host-core/tests/route_mute.rs index 5f514de8d..4c77dd245 100644 --- a/crates/host-core/tests/route_mute.rs +++ b/crates/host-core/tests/route_mute.rs @@ -2530,230 +2530,49 @@ fn the_following_route_charge_covers_its_attach_and_bind() { ); } -/// #1654 T5: stored send-row automation (route rows 1-6) on the send `t-b` of [`follow::model`]. -#[cfg(feature = "test-support")] -mod send_rows { - use super::sid; - use session::{ - Automation, AutomationOffset, AutomationSegment, AutomationShape, ControlAddress, Owner, - ParameterChannel, ParameterUnit, Section, - }; - - /// An entry `name` on `t-b`'s route row `row` (1 `gain_db`, 2 `mute`, 3-6 the matrix). - pub(super) fn entry(name: &str, row: u32, segments: Vec) -> Automation { - Automation { - id: sid(name), - target: ControlAddress { - owner: Owner::Route(sid("t-b")), - section: Section::Own, - control: row, - lane: ParameterChannel::Both, - }, - segments, - offset: AutomationOffset::default(), - } - } - - /// One segment of `shape` from `(start, from)` to `(end, to)` in `unit`. - pub(super) fn segment( - shape: AutomationShape, - (start, from): (u64, f32), - (end, to): (u64, f32), - unit: ParameterUnit, - ) -> AutomationSegment { - AutomationSegment { - shape, - start_sample: start, - end_sample: end, - start_value: from, - end_value: to, - unit, - } - } - - /// A flat curve at `value` in `unit`. - pub(super) fn flat(value: f32, unit: ParameterUnit) -> Vec { - vec![segment( - AutomationShape::Linear, - (0, value), - (1 << 20, value), - unit, - )] - } - - /// A linear gain ride from 0 dB at 0 to `end_db` at `end`. - pub(super) fn gain_ride(end: u64, end_db: f32) -> Vec { - vec![segment( - AutomationShape::Linear, - (0, 0.0), - (end, end_db), - ParameterUnit::Db, - )] - } -} - -/// #1654 gate 1 (host-core form, every bank width and the scalar oracle, every live form). The -/// send `t-b` (`pre_fader`, `rr` only) with a flat `gain_db` ride at -6 dB (hand offset `+0.0`, -/// then `-0.0` on both fields), a flat `rr` entry at 0.5 and a flat route `mute` at 0 renders, bit -/// for bit, the session whose send holds those values statically, and render allocates nothing -/// after the first block. Not vacuous: a flat ride at -5 dB differs. +/// #1654 U2 fold 1, MAJOR-3 on a mute-following send (every bank width and the scalar oracle). +/// The send `t-b` hears `t`'s right lane, whose mute entry holds it muted; `t`'s left lane has its +/// own mute entry too. A rebuild at block 6 removes the right lane's entry: the send stays a +/// following route (the left lane's curve keeps it seven-word), no join record moves its cell, and +/// no cursor of the successor's right column hears a curve. The carry lands the send's column on +/// the successor's static value over the removed curve's own length (the mute length): from the +/// first block boundary after that landing, the send's plane is the successor prepared fresh, bit +/// for bit, and render allocates nothing. /// -/// Test value: red if the route op reads the gain word as the compiler's linear static gain -/// rather than the seeded offset (the T4 note: `law(-6, 0.5)` is heard), converts the curve with -/// other arithmetic than the static gain's law, hears an automated matrix entry's fallback, or -/// retargets a flat ride. +/// Test value: red if a removed source mute curve of a send that stays seven-word leaves the send +/// on the removed curve's last value (muted). #[cfg(feature = "test-support")] #[test] -fn a_flat_send_ride_renders_the_static_sends_bits() { +fn removing_a_source_mute_curve_of_a_following_send_lands_on_the_fresh_plan() { use follow::*; - use send_rows::{entry, flat}; - use session::ParameterUnit; - const FRAMES: usize = 3_000; - let pcm = feed(FRAMES + 256, None); - let base = model(128, SendTap::PreFader, false, true); - let mut fixed = base.clone(); - fixed.routes[1].gain_db = -6.0; - fixed.routes[1].channel_matrix.rr = 0.5; - let automated = |gain_db: f32, offset: f32| { - let mut model = base.clone(); - let mut gain = entry("send-gain", 1, flat(gain_db, ParameterUnit::Db)); - gain.offset = session::AutomationOffset { - left_db: offset, - right_db: offset, - }; - model.automation = vec![ - gain, - entry( - "send-mute", - 2, - vec![send_rows::segment( - session::AutomationShape::Step, - (0, 0.0), - (1 << 20, 0.0), - ParameterUnit::Linear, - )], - ), - entry("send-rr", 6, flat(0.5, ParameterUnit::Linear)), - ]; - model + use successor::{BLOCKS, QUANTUM, SWAP_BLOCK}; + let pcm = successor::Feed::new("t", 29); + let lane_entry = |name: &str, lane: session::ParameterChannel, from: f32| { + let mut entry = mute_entry(from, &[]); + entry.id = sid(name); + entry.target.lane = lane; + entry }; - for backend in follow::backends() { - for live in LIVE_FORMS { - let what = format!("{backend:?} {live:?}"); - let fixed = run(&fixed, backend, live, &pcm, FRAMES); - for offset in [0.0_f32, -0.0] { - let curve = run(&automated(-6.0, offset), 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"); - for plane in 0..2 { - assert_bits_equal( - &curve.out[plane], - &fixed.out[plane], - &format!("{what}, offset {offset:?}: plane {plane}"), - ); - } - } - let other = run(&automated(-5.0, 0.0), backend, live, &pcm, FRAMES); - assert!( - first_difference_from(&other.out[1], &fixed.out[1], 0).is_some(), - "{what}: a ride at -5 dB is heard" - ); - } - } -} - -/// #1654 gate 2 (every bank width and the scalar oracle, quanta 128 and 100). The send `t-b` -/// rides its gain linearly from 0 to -24 dB over 4,000 samples; track `t` carries a latent -/// insert of `L` samples before its fader. With an `input` tap the send reads `t` `L` render -/// samples before the strip's fader and PDC delays the bus path by `L` at the output; with a -/// `post_fader` tap it reads at the fader's arrival, `L`. In both cases the send's plane (the -/// output's right plane) is, from sample `L` on, the plane of a twin with no latent insert shifted -/// by `L`, bit for bit: each grid value of the ride reaches the bus on the timeline sample the -/// twin's does, and the post-fader ride's ramps start on the strip's render samples. -/// -/// Test value: red if the send's events use the fader's arrival instead of the tap's (an `input` -/// tap would ride `L` samples late), the route's own arrival, or the render sample. -#[cfg(feature = "test-support")] -#[test] -fn a_send_ride_is_heard_at_its_taps_time() { - use follow::*; - use send_rows::{entry, gain_ride}; - const FRAMES: usize = 4_000; - let pcm = feed(FRAMES + 1_024, Some(0.25)); - for quantum in [128_u32, 100] { - for tap in [SendTap::Input, SendTap::PostFader] { - let with_ride = |latent: bool| { - let mut model = model(quantum, tap, latent, true); - model.automation = vec![entry("send-gain", 1, gain_ride(4_000, -24.0))]; - model - }; - let (latent, twin) = (with_ride(true), with_ride(false)); - for backend in follow::backends() { - let what = format!("q={quantum} {tap:?} {backend:?}"); - let late = run(&latent, backend, Live::None, &pcm, FRAMES); - let early = run(&twin, backend, Live::None, &pcm, FRAMES); - assert_eq!(late.allocations, (0, 0, 0), "{what}: render allocates"); - let shift = L as usize; - assert_bits_equal( - &late.out[1][shift..], - &early.out[1][..FRAMES - shift], - &format!("{what}: the send plane, shifted by the latency"), - ); - // Not vacuous: the ride moves over the compared window. - assert_ne!( - early.out[1][100].to_bits(), - early.out[1][3_000].to_bits(), - "{what}: the send rides" - ); - } - } - } -} - -/// #1654 gate 7 (every bank width and the scalar oracle). The send `t-b` rides its gain linearly -/// from 0 to -24 dB over 4,000 samples and its `ll` entry from 0 to 0.5. A rebuild at block 6 -/// that changes only the direct route `t-main`'s gain (a route into the output: a rebuild row) -/// keeps both tables, so the send's cursors carry verbatim: the send's plane (the output's right -/// plane) is bit for bit the never-swapped predecessor's in every block, and the swap allocates -/// and frees nothing. Not vacuous: the strip's plane moves with the rebuild, and the ride is -/// still moving at the swap. -/// -/// Test value: red if the plan does not hand the route op the rows whose tables both models hold -/// (`controls::continued_route_rows` through `GraphFollowRouteSource::with_continued_rows`): a -/// repositioned cursor re-times the running ride. -#[cfg(feature = "test-support")] -#[test] -fn a_running_send_ride_across_an_unrelated_rebuild_is_the_never_swapped_twin() { - use follow::*; - use send_rows::{entry, gain_ride, segment}; - use session::{AutomationShape, ParameterUnit}; - use successor::{QUANTUM, SWAP_BLOCK}; - let pcm = successor::Feed::new("t", 23); - let mut a = model(QUANTUM as u32, SendTap::PreFader, false, true); - a.automation = vec![ - entry("send-gain", 1, gain_ride(4_000, -24.0)), - entry( - "send-ll", - 3, - vec![segment( - AutomationShape::Linear, - (0, 0.0), - (4_000, 0.5), - ParameterUnit::Linear, - )], - ), - ]; - let mut b = a.clone(); - b.routes[0].gain_db = -3.0; - assert_eq!(b.routes[0].id.as_str(), "t-main"); + let mut b = model(QUANTUM as u32, SendTap::PreFader, false, true); + b.automation = vec![lane_entry( + "mute-left", + session::ParameterChannel::Left, + 0.0, + )]; + let mut a = b.clone(); + a.automation.push(lane_entry( + "mute-right", + session::ParameterChannel::Right, + 1.0, + )); let quiet = |_: usize, _: &mut host_core::HostLiveControlHandles, _: &mut engine::realtime::PlanRevisionGate| {}; for backend in follow::backends() { let what = format!("{backend:?}"); - let (twin, _) = live_swap(&a, None, backend, &pcm, quiet); + let fresh = + successor::reference_run(&successor::Session::compile(b.clone()), &[&pcm], backend); + let fresh = plane(&fresh, 1); let (swapped, handles) = live_swap(&a, Some((&b, &a)), backend, &pcm, quiet); assert_eq!( handles @@ -2761,200 +2580,20 @@ fn a_running_send_ride_across_an_unrelated_rebuild_is_the_never_swapped_twin() { .follow_route_controls .len(), 1, - "{what}: the send is a following route" + "{what}: the send stays a following route" ); assert_quiet(&swapped, &what); - let (send, twin_send) = (plane(&swapped, 1), plane(&twin, 1)); - assert_bits_equal(&send, &twin_send, &format!("{what}: the send's plane")); + let send = plane(&swapped, 1); let at = SWAP_BLOCK * QUANTUM; assert!( - first_difference_from(&plane(&swapped, 0), &plane(&twin, 0), at).is_some(), - "{what}: the rebuild is heard on the strip's plane" - ); - assert_ne!( - twin_send[at].to_bits(), - twin_send[at + QUANTUM].to_bits(), - "{what}: the ride moves at the swap" - ); - } -} - -/// #1654 root ruling B19-R7 (every bank width and the scalar oracle). The send `t-b` hears flat -/// `ll` and `rr` entries at 0.5; at block 5 a hand move of its gain to -6 dB is written through -/// its seven-word producer, and a rebuild at block 6 removes the `ll` entry while the move is in -/// flight: the send's left source column no longer hears a curve. Until the swap both planes are -/// the never-swapped twin's; at the swap the left column lands at once over `SendMatrix` (the pan -/// length), cutting the move there (the control plane flags that rebuild), while the right -/// column's move runs on; from the first block boundary after the move's end and the landing both -/// planes are the successor prepared fresh, bit for bit. Render allocates nothing. -/// -/// Test value: red if the removal never lands, lands late (after the move), or leaves the removed -/// row's value on the column. -#[cfg(feature = "test-support")] -#[test] -fn a_send_column_losing_its_last_curve_lands_at_once_across_the_hand_move() { - use follow::*; - use send_rows::{entry, flat}; - use session::ParameterUnit; - use successor::{BLOCKS, QUANTUM, SWAP_BLOCK}; - const WRITE_BLOCK: usize = 5; - let pcm = successor::Feed::new("t", 31); - let mut a = model(QUANTUM as u32, SendTap::PreFader, false, true); - a.automation = vec![ - entry("send-ll", 3, flat(0.5, ParameterUnit::Linear)), - entry("send-rr", 6, flat(0.5, ParameterUnit::Linear)), - ]; - let mut committed = a.clone(); - committed.routes[1].gain_db = -6.0; - assert_eq!(committed.routes[1].id.as_str(), "t-b"); - let mut b = committed.clone(); - b.automation.retain(|entry| entry.id.as_str() != "send-ll"); - // The record a commit of `committed` writes: the open transform at -6 dB (the automated - // entries' words are fallbacks render ignores) and the plan's automated set. - let length = 480_usize; - let transform = - graph_compiler::route_values(-6.0, [0.0, 0.0, 0.0, 1.0]).expect("in-domain send values"); - let pan = controls::LiveRamps::for_session(&b).pan_samples as usize; - let move_end = WRITE_BLOCK * QUANTUM + length; - assert!( - move_end > SWAP_BLOCK * QUANTUM, - "the move is in flight at the swap" - ); - let at = SWAP_BLOCK * QUANTUM; - let landed = next_boundary(move_end.max(at + pan), QUANTUM); - assert!(landed < BLOCKS * QUANTUM, "the landing ends inside the run"); - let hand = |block: usize, - handles: &mut host_core::HostLiveControlHandles, - gate: &mut engine::realtime::PlanRevisionGate| { - use builtins_compiler::TestOnlyWriteGate; - if block == WRITE_BLOCK { - let index = - host_core::follow_route_producer_position(&handles.follow_route_controls, "t-b") - .expect("t-b's seven-word producer"); - let automated = handles.follow_route_controls[index].automated(); - let [automated_gain, automated_mute, ll, lr, rl, rr] = automated.rows; - let flags = host_core::RouteFollowFlags { - mute: false, - follows_mute: true, - terms: [false; 2], - automated: automated.lanes, - automated_gain, - automated_mute, - automated_matrix: [ll, lr, rl, rr], - }; - let record = host_core::FollowRouteControlRecord::new(transform, flags, length as u32) - .expect("a send record"); - gate.write_published(|stamp| { - handles.follow_route_controls[index].write(stamp, record); - }); - } - }; - for backend in follow::backends() { - let what = format!("{backend:?}"); - let (twin, _) = live_swap(&a, None, backend, &pcm, hand); - let (swapped, handles) = live_swap(&a, Some((&b, &committed)), backend, &pcm, hand); - assert_eq!( - handles - .expect("the successor's handles") - .follow_route_controls - .len(), - 1, - "{what}: the send stays a following route" + first_difference_from(&send[..at], &fresh[..at], 0).is_some(), + "{what}: not vacuous, the removed curve is heard before the swap" ); - assert_quiet(&swapped, &what); - let fresh = - successor::reference_run(&successor::Session::compile(b.clone()), &[&pcm], backend); - for index in 0..2 { - let (send, twin, fresh) = ( - plane(&swapped, index), - plane(&twin, index), - plane(&fresh, index), - ); - assert_bits_equal( - &send[..at], - &twin[..at], - &format!("{what}: plane {index}, the twin until the swap"), - ); - assert_bits_equal( - &send[landed..BLOCKS * QUANTUM], - &fresh[landed..BLOCKS * QUANTUM], - &format!("{what}: plane {index}, the fresh plan after the landing"), - ); - } - // The landing starts at the swap: inside the move, the planes leave the twin's. - assert!( - (0..2).any(|index| { - first_difference_from( - &plane(&swapped, index)[..move_end], - &plane(&twin, index)[..move_end], - at, - ) - .is_some() - }), - "{what}: the left column lands at the swap, inside the move" + let landed = next_boundary(at + M as usize, QUANTUM); + assert_bits_equal( + &send[landed..BLOCKS * QUANTUM], + &fresh[landed..BLOCKS * QUANTUM], + &format!("{what}: the fresh plan after the landing"), ); } } - -/// #1654 U2 fold 1, MAJOR-3 (every bank width and the scalar oracle). The send `t-b` keeps a flat -/// `gain_db` ride at -6 dB (its table continues) and a rebuild at block 6 removes either its flat -/// `rr` entry at 0.5 or its source strip's mute entry (held muted): the send stays a following -/// route, no join record moves its cell, and no cursor of the successor hears the removed curve. -/// The carry lands the send on the successor's static value over the removed row's own length -/// (`SendMatrix`, the pan length, for `rr`; the mute length for the mute curve): from the first -/// block boundary after that landing, the send's plane is the successor prepared fresh, bit for -/// bit, and render allocates nothing. -/// -/// Test value: red if a removed row or source mute curve of a send that stays seven-word leaves -/// the send on the removed curve's last value. -#[cfg(feature = "test-support")] -#[test] -fn removing_a_send_row_or_its_source_mute_curve_lands_on_the_fresh_plan() { - use follow::*; - use send_rows::{entry, flat}; - use session::ParameterUnit; - use successor::{BLOCKS, QUANTUM, SWAP_BLOCK}; - let pcm = successor::Feed::new("t", 29); - let mut b = model(QUANTUM as u32, SendTap::PreFader, false, true); - b.automation = vec![entry("send-gain", 1, flat(-6.0, ParameterUnit::Db))]; - let pan = controls::LiveRamps::for_session(&b).pan_samples as usize; - let mut with_rr = b.clone(); - with_rr - .automation - .push(entry("send-rr", 6, flat(0.5, ParameterUnit::Linear))); - let mut with_mute = b.clone(); - with_mute.automation.push(mute_entry(1.0, &[])); - let quiet = |_: usize, - _: &mut host_core::HostLiveControlHandles, - _: &mut engine::realtime::PlanRevisionGate| {}; - for backend in follow::backends() { - let fresh = - successor::reference_run(&successor::Session::compile(b.clone()), &[&pcm], backend); - let fresh = plane(&fresh, 1); - for (name, a, length) in [("rr", &with_rr, pan), ("mute", &with_mute, M as usize)] { - let what = format!("{backend:?} {name}"); - let (swapped, handles) = live_swap(a, Some((&b, a)), backend, &pcm, quiet); - assert_eq!( - handles - .expect("the successor's handles") - .follow_route_controls - .len(), - 1, - "{what}: the send stays a following route" - ); - assert_quiet(&swapped, &what); - let send = plane(&swapped, 1); - let at = SWAP_BLOCK * QUANTUM; - assert!( - first_difference_from(&send[..at], &fresh[..at], 0).is_some(), - "{what}: not vacuous, the removed curve is heard before the swap" - ); - let landed = next_boundary(at + length, QUANTUM); - assert_bits_equal( - &send[landed..BLOCKS * QUANTUM], - &fresh[landed..BLOCKS * QUANTUM], - &format!("{what}: the fresh plan after the landing"), - ); - } - } -} diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index 59ca283c1..2b35e0244 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, mute, send and VCA rows render; every other row is inert today.** An entry on a strip's fader +**The fader, mute and VCA 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 @@ -306,18 +306,8 @@ curve and a hand unmute leaves a closed curve closed. Only a change of that OR m 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. An entry on a send's own rows (#1654: a route into a submix, rows 1 `gain_db`, -2 `mute` and 3-6 `matrix_ll`, `matrix_lr`, `matrix_rl`, `matrix_rr`) is compiled into the same table -after every mute entry and rendered on the C ABI and in the browser alike: the send follows its -curves in the node time of its tap (`input`, `pre_fader`, ...), so a pre-insert send behind a latent -insert moves on the timeline sample of the audio it carries. The gain and matrix rows take the -fader's grid (a 64-sample retarget toward the curve's value at each grid end) and jump over the -session's send gain and send matrix ramp lengths (`fader_ms`, `pan_ms`); the send `mute` row is -`step` only and moves over the mute ramp length. A flat ride renders the bits of the same static -send. The send's mute is the curve's OR its static (hand) `mute`, as the strip's is; a hand edit of -an automated matrix row is stored and commits `model_only` (automation wins), and a live edit of a -send row the session does not automate stays live. An entry on a VCA's own rows (#1655: row 1 -`offset_db` and row 2 `mute`, per lane) is compiled into the same table after every send entry, one +unchanged finishes. An entry on a VCA's own rows (#1655: row 1 +`offset_db` and row 2 `mute`, per lane) is compiled into the same table after every mute entry, one table per automated VCA lane, and rendered on the C ABI and in the browser alike through the VCA's members, since a VCA has no audio: each member lane the VCA reaches, directly or through nested VCAs, follows the VCA curves in its own fader node time, so a member behind a latent insert hears @@ -336,9 +326,11 @@ automated VCA `offset_db` writes the entry's offset (live), a hand VCA mute ORs (live), and a hand fader move of a member with no fader entry of its own stays live. An edit of a VCA entry, and a change of which member lanes an automated VCA reaches (membership, nesting, or an automated VCA added or removed), is a rebuild; a VCA no entry drives keeps its live edits. A -session without VCA automation renders the bits it rendered before. A send's `follows_mute` (row 7) -is not automatable (#1632). A route into the output stays prepared: its rows are rebuild-only, so an entry -on one is refused `control.automation.target`. Stored automation has no queue: render generates its events from the plan. An entry on any other row, +session without VCA automation renders the bits it rendered before. Send rows do not render: an +entry on a send's own rows (a route into a submix, rows 1-6) is valid and inert, as below (#1654 +renders them later). A route into the output stays prepared: its rows are rebuild-only, so an +entry on one is refused `control.automation.target`. 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 @@ -368,12 +360,9 @@ fields 1 and 2. A missing record or lane is `schema.missing_field` and a third k `schema.unknown_field`, at their paths. The row rule (`controls::automation_diagnostics`) judges each lane at `$.automation[id=].offset.left_db` or `.right_db`, after the segments: -- `fader_db` (builtins control 5 of a strip), a send's `gain_db` (own control 1 of a route into - a submix, #1654) and a VCA's `offset_db` (own control 1, per lane, #1655) take an offset - (`takes_offset`). The input trim does not yet: the slice that - renders it (#1658) adds it. The send gain row is shared, so its entry holds one offset: `right_db` - must have the bits of `left_db` (`control.automation.offset` at `.offset.right_db` otherwise, - `+0.0` with `-0.0` included), in the route gain domain `[-144, 24]` dB. +- `fader_db` (builtins control 5 of a strip) and a VCA's `offset_db` (own control 1, per lane, + #1655) take an offset (`takes_offset`). The input trim and the send gain do not yet: the slices + that render them (#1658, #1654) add them. - A lane that takes no offset holds exactly `+0.0`, else `control.automation.offset`. That is a lane the entry does not drive (`right_db` of a `left` entry, `left_db` of a `right` entry) and every lane of an entry whose row takes no offset. `-0.0` is refused there too, so "no offset" @@ -394,11 +383,7 @@ ID (with VCA automation, the order of the VCA rows above). On a lane a rendered not a control value: the static fallback changes only through a rebuild (remove the automation, set the fader, upsert the automation, in one transaction) or a document replacement, and any edit that changes it on an automated lane is a change of structure. An `UpsertAutomation` that changes -only an entry's offset is live, as a hand move is. A send's automated `gain_db` takes its offset -the same way (#1654): render hears `db_to_gain(clamp(f64(curve) + f64(offset), -144, 24))`, rounded once -to `f32` before the conversion; `SET_CONTROL gain_db` on that send writes the entry's offset (both -fields, live), the control list reports the offset as the row's value and marks the row -`automated`, and the static `gain_db` is the fallback. +only an entry's offset is live, as a hand move is. **The input filter order, on curves (#1645).** On each strip lane where HPF (3) or LPF (4) is automated (a `both` entry drives both lanes), each side is its entry under the hold rule, or the diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 7d4f32a9a..eafe24e03 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -6429,117 +6429,15 @@ fn solo_and_a_vca_mute_compose_with_the_curve_for_a_following_send_on_both_hosts } } -/// #1654 T4: [`follow_model`]`(false, false)` (no source mute curve) with the send `t1-b` riding -/// its `gain_db` (route row 1) linearly from 0 to -12 dB over the run, and `change` applied: a -/// route-row-only send, which the plan gives the seven-word cell. +/// An entry `name` on `t1-b`'s own row `row` (1 `gain_db`, 2 `mute`, 3-6 the matrix), one segment +/// of `shape` over `[0, end)` from `from` to `to` in `unit`. #[cfg(feature = "test-support")] -fn send_ride_model(change: impl FnOnce(&mut SessionModel)) -> SessionModel { - let mut model = follow_model(false, false); - model.automation.push(session::Automation { - id: id("t1-b-gain-ride"), - target: address( - Owner::Route(id("t1-b")), - Section::Own, - 1, - ParameterChannel::Both, - ), - segments: vec![session::AutomationSegment { - shape: session::AutomationShape::Linear, - start_sample: 0, - end_sample: u64::from(QUANTUM) * BLOCKS, - start_value: 0.0, - end_value: -12.0, - unit: session::ParameterUnit::Db, - }], - offset: session::AutomationOffset::default(), - }); - change(&mut model); - model -} - -/// #1654 R17-6 and root's ruling R17-1 on both hosts. On `t1-b`, whose `gain_db` stored entry -/// renders, `SET_CONTROL gain_db = +3` is `live` alike on both hosts, one revision, with one -/// reply; the committed document stores `+3` as the entry's offset on both fields and keeps the -/// static gain. The in-place leg commits in place on both hosts; the same transaction by the full -/// path (`control_plane::test_force_full_preparation`) gives the same reply, document and bits of -/// every block, alike on both hosts. -/// -/// Test value: red if the in-place path answers or renders otherwise than the full path for the -/// send gain (an owners model without the route entry writes the static gain or commits -/// `model_only`), or if the two hosts disagree. -#[cfg(feature = "test-support")] -#[test] -fn a_hand_move_on_an_automated_send_gain_is_an_offset_alike_on_both_hosts() { - const EDIT: u64 = 4; - const END: u64 = 20; - let model = send_ride_model(|_| {}); - let mut expected = send_ride_model(|model| { - model.automation[0].offset = session::AutomationOffset { - left_db: 3.0, - right_db: 3.0, - }; - }); - expected.revision = model.revision + 1; - let edits = [own_edit(Owner::Route(id("t1-b")), 1, 3.0)]; - let leg = |label: &str| { - let mut browser = Rig::new(&model); - let mut c_abi = CAbi::new(&model); - same_blocks(&mut browser, &mut c_abi, 0..EDIT, label); - let (b, reply) = browser.transaction_reply(&edits); - let (c, c_reply) = c_abi.transaction(&edits); - assert_eq!(b, c, "{label}: one verdict"); - assert_eq!(reply, c_reply, "{label}: one reply"); - assert_eq!( - b, - Verdict::Applied { - path: EditPath::Live, - revision: model.revision + 1 - }, - "{label}: a hand move is live" - ); - let committed = snapshot_bytes(|payload, expected| browser.command(payload, expected)); - let blocks: Vec> = (EDIT..END) - .map(|block| { - let (b, c) = (browser.step(block), c_abi.step(block)); - assert_eq!(b, c, "{label}: block {block} alike on both hosts"); - b - }) - .collect(); - (reply, committed, blocks) - }; - control_plane::test_take_in_place_counts(); - let in_place = leg("in place"); - assert_eq!( - control_plane::test_take_in_place_counts(), - (2, 0), - "both hosts commit the hand move in place" - ); - assert_eq!( - in_place.1, - canonical_session_json(&expected) - .expect("canonical") - .as_bytes(), - "the entry stores +3 on both fields and the static gain stays" - ); - control_plane::test_force_full_preparation(true); - let full = leg("full path"); - control_plane::test_force_full_preparation(false); - assert_eq!( - control_plane::test_take_in_place_counts().0, - 0, - "the full leg commits nothing in place" - ); - assert_eq!(full.0, in_place.0, "one reply on both paths"); - assert_eq!(full.1, in_place.1, "one document"); - assert_eq!(full.2, in_place.2, "the same bits on both paths"); -} - -/// #1654 T5: an entry `name` on `t1-b`'s own row `row` (1 `gain_db`, 2 `mute`, 3-6 the matrix). -#[cfg(feature = "test-support")] -fn send_entry( +fn send_row_entry( name: &str, row: u32, - segments: Vec, + shape: session::AutomationShape, + (from, to): (f32, f32), + unit: session::ParameterUnit, ) -> session::Automation { session::Automation { id: id(name), @@ -6549,280 +6447,18 @@ fn send_entry( row, ParameterChannel::Both, ), - segments, - offset: session::AutomationOffset::default(), - } -} - -/// #1654 T5: one segment of `shape` from `(start, from)` to `(end, to)` in `unit`. -#[cfg(feature = "test-support")] -fn send_piece( - shape: session::AutomationShape, - (start, from): (u64, f32), - (end, to): (u64, f32), - unit: session::ParameterUnit, -) -> session::AutomationSegment { - session::AutomationSegment { - shape, - start_sample: start, - end_sample: end, - start_value: from, - end_value: to, - unit, - } -} - -/// #1654 T5: `t1-b`'s own row `control` set to `value`, over `ramp` samples when given. -#[cfg(feature = "test-support")] -fn send_edit(control: u32, value: f32, ramp: Option) -> Vec { - vec![SessionEdit::set_control( - address(Owner::Route(id("t1-b")), Section::Own, control, Lane::Both), - value, - ramp, - )] -} - -/// #1654 T5: the send `t1-b` of `model`. -#[cfg(feature = "test-support")] -fn t1_b(model: &mut SessionModel) -> &mut session::Route { - model - .routes - .iter_mut() - .find(|route| route.id.as_str() == "t1-b") - .expect("the send") -} - -/// #1654 gate 1 on both hosts. `t1-b` with a flat `gain_db` ride at -6 dB (hand offset `+0.0`, -/// then `-0.0`), a flat `rr` entry at 0.5 and a flat route `mute` at 0 renders, on each host and -/// alike, the bits of the session whose send holds those values statically. Not vacuous: a ride -/// at -5 dB differs. -/// -/// Test value: red if either host's route op reads the gain word as the compiler's linear static -/// gain rather than the seeded offset, or composes the curves with other arithmetic than the -/// static route, or if the hosts disagree. -#[cfg(feature = "test-support")] -#[test] -fn a_flat_send_ride_renders_the_static_sends_bits_on_both_hosts() { - use session::{AutomationShape, ParameterUnit}; - const RUN: u64 = 24; - let end = u64::from(QUANTUM) * BLOCKS; - let flat = |value: f32, shape, unit| vec![send_piece(shape, (0, value), (end, value), unit)]; - let automated = |gain_db: f32, offset: f32| { - let mut model = follow_model(false, false); - let mut gain = send_entry( - "t1-b-gain", - 1, - flat(gain_db, AutomationShape::Linear, ParameterUnit::Db), - ); - gain.offset = session::AutomationOffset { - left_db: offset, - right_db: offset, - }; - model.automation.extend([ - gain, - send_entry( - "t1-b-mute", - 2, - flat(0.0, AutomationShape::Step, ParameterUnit::Linear), - ), - send_entry( - "t1-b-rr", - 6, - flat(0.5, AutomationShape::Linear, ParameterUnit::Linear), - ), - ]); - model - }; - let mut fixed = follow_model(false, false); - t1_b(&mut fixed).gain_db = -6.0; - t1_b(&mut fixed).channel_matrix.rr = 0.5; - let fixed = both_run(&fixed, RUN, &[]); - for offset in [0.0_f32, -0.0] { - assert_eq!( - both_run(&automated(-6.0, offset), RUN, &[]), - fixed, - "offset {offset:?}: the static send's bits" - ); - } - assert_ne!( - both_run(&automated(-5.0, 0.0), RUN, &[]), - fixed, - "a ride at -5 dB is heard" - ); -} - -/// #1654 gate 3 on both hosts. On [`send_ride_model`] (`t1-b` rides its gain from 0 to -12 dB) -/// with an `rr` ride from 1 to 0.25, each host gives one verdict and reply and renders the same -/// bits: (a) an `UpsertAutomation` that changes the gain ride rebuilds and completes `EXACT`, and -/// once the moved row has landed renders a fresh plan of the new model; (b) `SET_CONTROL gain_db = -/// +3` is `live` and, after its ramp and the deferred landing, renders a fresh plan whose entry -/// carries the offset `+3`; (c) `SET_CONTROL matrix_rr` (automated) is `model_only` and moves no -/// bit; (d) `SET_CONTROL matrix_lr = 0.5` (not automated) is `live` and, after its ramp, renders a -/// fresh plan with that value. -/// -/// Test value: red if a send automation edit is not an exact rebuild on either host, a hand send -/// gain move writes the fallback (or reaches the matrix), a hand edit of an automated matrix row -/// fights the curve, an un-automated value of the group is lost, or the hosts disagree. -#[cfg(feature = "test-support")] -#[test] -fn send_row_edits_rebuild_or_commit_as_the_rows_rule_says_on_both_hosts() { - use session::{AutomationShape, ParameterUnit}; - const RUN: u64 = 24; - const EDIT: u64 = 4; - const SETTLED: usize = 10; - let end = u64::from(QUANTUM) * BLOCKS; - let with_rr = |change: &dyn Fn(&mut SessionModel)| { - send_ride_model(|model| { - model.automation.push(send_entry( - "t1-b-rr", - 6, - vec![send_piece( - AutomationShape::Linear, - (0, 1.0), - (end, 0.25), - ParameterUnit::Linear, - )], - )); - change(model); - }) - }; - let model = with_rr(&|_| {}); - let untouched = both_run(&model, RUN, &[]); - // (a) A changed gain ride: an exact rebuild, then the new model's bits. - let changed = with_rr(&|model| model.automation[0].segments[0].end_value = -24.0); - let mut browser = Rig::new(&model); - let mut c_abi = CAbi::new(&model); - same_blocks(&mut browser, &mut c_abi, 0..EDIT, "(a)"); - both_commit( - &mut browser, - &mut c_abi, - &[SessionEdit::UpsertAutomation { - automation: changed.automation[0].clone(), + segments: vec![session::AutomationSegment { + shape, + start_sample: 0, + end_sample: u64::from(QUANTUM) * BLOCKS, + start_value: from, + end_value: to, + unit, }], - EditPath::Rebuild, - "(a)", - ); - let fresh = both_run(&changed, RUN, &[]); - for block in EDIT..RUN { - let (b, c) = (browser.step(block), c_abi.step(block)); - assert_eq!(b, c, "(a), block {block}: the two hosts agree"); - if block as usize >= SETTLED { - assert_eq!( - b, fresh[block as usize], - "(a), block {block}: the new model's bits" - ); - } - } - let watermark = c_abi.watermark(); - assert_eq!(status_watermark(&browser), watermark, "(a): one watermark"); - assert_eq!( - watermark.2, - capi::OUTCOME_EXACT, - "(a): the rebuild is exact" - ); - // (b), (c), (d). - let offset = with_rr(&|model| { - model.automation[0].offset = session::AutomationOffset { - left_db: 3.0, - right_db: 3.0, - }; - }); - let lr = with_rr(&|model| t1_b(model).channel_matrix.lr = 0.5); - for (label, edits, path, expected) in [ - ( - "(b) the gain offset", - send_edit(1, 3.0, None), - EditPath::Live, - &offset, - ), - ( - "(c) an automated matrix_rr", - send_edit(6, 0.75, None), - EditPath::ModelOnly, - &model, - ), - ( - "(d) an un-automated matrix_lr", - send_edit(4, 0.5, None), - EditPath::Live, - &lr, - ), - ] { - let run = both_run(&model, RUN, &[(EDIT, edits, path)]); - let fresh = both_run(expected, RUN, &[]); - let from = if path == EditPath::ModelOnly { - 0 - } else { - SETTLED - }; - assert_eq!( - run[from..], - fresh[from..], - "{label}: the committed model's bits" - ); - if path == EditPath::Live { - assert_ne!( - run[EDIT as usize..], - untouched[EDIT as usize..], - "{label}: heard" - ); - } + offset: session::AutomationOffset::default(), } } -/// #1654 gate 4 on both hosts (D5). `t1-b`'s own mute curve is open until 2,048 and closed after. -/// A hand mute before block 4 is `live` and, after the mute ramp, renders the plan whose send is -/// statically muted (the curve is open: the hand mute closes the send); a hand unmute before -/// block 20 (2,560, the curve closed) is `live` and renders the curve-only plan (the send stays -/// closed). Both hosts render the same bits. -/// -/// Test value: red if the composition is not an OR on either host, or the hosts disagree. -#[cfg(feature = "test-support")] -#[test] -fn a_hand_send_mute_ors_with_its_curve_on_both_hosts() { - use session::{AutomationShape::Step, ParameterUnit::Linear}; - const RUN: u64 = 32; - let end = u64::from(QUANTUM) * BLOCKS; - let curve = |muted: bool| { - let mut model = follow_model(false, false); - model.automation.push(send_entry( - "t1-b-mute", - 2, - vec![ - send_piece(Step, (0, 0.0), (2_048, 1.0), Linear), - send_piece(Step, (2_048, 1.0), (end, 1.0), Linear), - ], - )); - t1_b(&mut model).mute = muted; - model - }; - let run = both_run( - &curve(false), - RUN, - &[ - (4, send_edit(2, 1.0, None), EditPath::Live), - (20, send_edit(2, 0.0, None), EditPath::Live), - ], - ); - let shut = both_run(&curve(true), RUN, &[]); - let curve_only = both_run(&curve(false), RUN, &[]); - assert_eq!( - run[9..20], - shut[9..20], - "the hand mute closes the open curve" - ); - assert_ne!( - curve_only[9..16], - shut[9..16], - "not vacuous: the curve is open there" - ); - assert_eq!( - run[20..], - curve_only[20..], - "the hand unmute leaves the closed curve closed" - ); -} - /// #1654 gate 5 on both hosts: an automation entry on the route into the output `t1-main` is /// refused `control.automation.target` alike, with one diagnostic. /// @@ -6834,15 +6470,12 @@ fn an_output_route_entry_is_refused_alike_on_both_hosts() { let mut browser = Rig::new(&model); let mut c_abi = CAbi::new(&model); same_blocks(&mut browser, &mut c_abi, 0..1, "before"); - let mut entry = send_entry( + let mut entry = send_row_entry( "t1-main-gain", 1, - vec![send_piece( - session::AutomationShape::Linear, - (0, -6.0), - (1_000, -6.0), - session::ParameterUnit::Db, - )], + session::AutomationShape::Linear, + (-6.0, -6.0), + session::ParameterUnit::Db, ); entry.target.owner = Owner::Route(id("t1-main")); let edits = [SessionEdit::UpsertAutomation { automation: entry }]; @@ -6868,181 +6501,324 @@ fn an_output_route_entry_is_refused_alike_on_both_hosts() { same_blocks(&mut browser, &mut c_abi, 1..3, "after the refusal"); } -/// #1654 gate 7 and R19-5 on both hosts. On [`send_ride_model`]: (gate 7) a rebuild that adds a -/// muted send `t2-b` into the bus (inactive: no bit of the mix moves) keeps the send ride's table, so the -/// send's cursors carry verbatim and every block is the never-rebuilt run's, bit for bit; -/// (R19-5) a live send move with its own 4,000 samples (the hand offset `+3`, or a D6 -/// `matrix_lr` edit) is in flight when a rebuild moves the gain ride's table: until the move's -/// end every block is the run that took the move and no rebuild. Gate 7's rebuild completes -/// `EXACT` with no transition fallback on either host; R19-5's completes `TRANSITION_FALLBACK` -/// (count 1), B19-R8's conservative term, though its move is not cut. Both hosts render the same -/// bits. +/// Root ruling B19-R10 (send-row rendering is not shipped; #1654 turns it on behind TBD54). A +/// session whose send `t1-b` stores automation on its own rows (a `gain_db` ride, a `mute` step, +/// rides and flat entries on `ll`, `lr`, `rl` and `rr`) behaves exactly as the session without +/// those entries, as on `main` before batch 19: on both hosts, with `t1-b` six-word (no source +/// mute curve) and seven-word (`t1`'s stored mute), the session prepares with no refusal, every +/// block renders the bits of the session without the entries (through a live `gain_db` move on +/// the send and an inert entry upsert, which commits `model_only`), and the control list and +/// state pages are those of the session without the entries (no send row lists `automated`). /// -/// Test value: red if a continued ride is repositioned at an unrelated swap (a re-timed ride), if a -/// reposition cuts or re-times a live send move, or if gate 7's rebuild (no live send move) is -/// flagged. +/// Test value: red if a send-row entry reaches render (a cell, a seven-word lane or a hand +/// offset: the bits move), if it is refused or changes an edit's path, or if the control list +/// flags or values a send row from it. #[cfg(feature = "test-support")] #[test] -fn send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts() { - const RUN: u64 = 48; - const MOVE: u64 = 4; - const REBUILD: u64 = 6; - const RAMP: u32 = 4_000; - let model = send_ride_model(|_| {}); - // A muted route `t2-b` into the bus: inactive, so the bus sum takes nothing of it. - let mut muted_send = model +fn stored_send_row_automation_is_inert_alike_on_both_hosts() { + use session::{AutomationShape, ParameterUnit}; + const RUN: u64 = 32; + let gain = send_row_edit(1, -6.0, Some(480)); + let upsert = vec![SessionEdit::UpsertAutomation { + automation: send_row_entry( + "t1-b-lr", + 4, + AutomationShape::Linear, + (0.7, 0.7), + ParameterUnit::Linear, + ), + }]; + let edits = [(4, gain, EditPath::Live), (10, upsert, EditPath::ModelOnly)]; + for (label, base) in [ + ("six-word send", follow_model(false, false)), + ("seven-word send", follow_model(true, false)), + ] { + let mut rows = base.clone(); + rows.automation.extend([ + send_row_entry( + "t1-b-gain", + 1, + AutomationShape::Linear, + (0.0, -12.0), + ParameterUnit::Db, + ), + send_row_entry( + "t1-b-mute", + 2, + AutomationShape::Step, + (0.0, 1.0), + ParameterUnit::Linear, + ), + send_row_entry( + "t1-b-ll", + 3, + AutomationShape::Linear, + (0.1, 0.9), + ParameterUnit::Linear, + ), + send_row_entry( + "t1-b-lr", + 4, + AutomationShape::Linear, + (0.5, 0.5), + ParameterUnit::Linear, + ), + send_row_entry( + "t1-b-rl", + 5, + AutomationShape::Linear, + (0.25, 0.25), + ParameterUnit::Linear, + ), + send_row_entry( + "t1-b-rr", + 6, + AutomationShape::Linear, + (0.9, 0.1), + ParameterUnit::Linear, + ), + ]); + let (mut browser, mut c_abi) = (Rig::new(&rows), CAbi::new(&rows)); + let (mut plain_browser, mut plain_c_abi) = (Rig::new(&base), CAbi::new(&base)); + let (b_rows, _) = dump(|payload, expected| browser.command(payload, expected)); + let (c_rows, _) = dump(|payload, expected| c_abi.send(payload, expected)); + let (plain_rows, _) = dump(|payload, expected| plain_browser.command(payload, expected)); + let (plain_c_rows, _) = dump(|payload, expected| plain_c_abi.send(payload, expected)); + assert_eq!(b_rows, c_rows, "{label}: the hosts list alike"); + assert_eq!( + plain_rows, plain_c_rows, + "{label}: the hosts list alike, no entries" + ); + assert_eq!( + b_rows, plain_rows, + "{label}: the list ignores the send-row entries" + ); + assert!( + !b_rows + .iter() + .any(|row| row.contains("\"t1-b\"") && row.contains("automated: true")), + "{label}: no send row lists automated" + ); + let run = both_run(&rows, RUN, &edits); + let plain = both_run(&base, RUN, &edits); + for (block, (run, plain)) in run.iter().zip(&plain).enumerate() { + assert_eq!(run, plain, "{label}, block {block}: the entries are inert"); + } + } +} + +/// `t1-b`'s own row `control` set to `value`, over `ramp` samples when given. +#[cfg(feature = "test-support")] +fn send_row_edit(control: u32, value: f32, ramp: Option) -> Vec { + vec![SessionEdit::set_control( + address(Owner::Route(id("t1-b")), Section::Own, control, Lane::Both), + value, + ramp, + )] +} + +/// [`follow_model`]`(false, false)` whose send `t1-b` follows a stored mute curve held open (`t1`'s +/// mute entry flat at 0 on both lanes): a seven-word following send whose curves never move, so a +/// live send move on it is the only ramp in flight. +#[cfg(feature = "test-support")] +fn open_follow_model() -> SessionModel { + let mut model = follow_model(false, false); + model.automation.push(session::Automation { + id: id("t1-mute"), + target: address( + Owner::Strip(id(T1.id)), + Section::Builtins, + MUTE, + ParameterChannel::Both, + ), + segments: vec![session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: 0, + end_sample: u64::from(QUANTUM) * BLOCKS, + start_value: 0.0, + end_value: 0.0, + unit: session::ParameterUnit::Linear, + }], + offset: session::AutomationOffset::default(), + }); + model +} + +/// A rebuild that adds the muted, inactive send `t2-b` into the bus: no bit of the mix moves. +#[cfg(feature = "test-support")] +fn muted_send_rebuild(model: &SessionModel) -> Vec { + let mut send = model .routes .iter() .find(|route| route.id.as_str() == "t1-b") .expect("the send") .clone(); - muted_send.id = id("t2-b"); - muted_send.source = session::RouteSource::Track { + send.id = id("t2-b"); + send.source = session::RouteSource::Track { track_id: id(T2.id), tap: session::SendTap::PreFader, }; - muted_send.mute = true; - muted_send.follows_mute = false; - let added_route = SessionEdit::UpsertRoute { route: muted_send }; - let moved_ride = |offset: f32| { - let mut ride = model.automation[0].clone(); - ride.segments[0].end_value = -24.0; - ride.offset = session::AutomationOffset { - left_db: offset, - right_db: offset, - }; - SessionEdit::UpsertAutomation { automation: ride } - }; - let outcome = |browser: &mut Rig, c_abi: &CAbi, label: &str, flagged: bool| { - let watermark = c_abi.watermark(); - assert_eq!( - status_watermark(browser), - watermark, - "{label}: one watermark" - ); - let expected = if flagged { - capi::OUTCOME_TRANSITION_FALLBACK - } else { - capi::OUTCOME_EXACT - }; - assert_eq!(watermark.2, expected, "{label}: the rebuild's outcome"); - assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); - let count = u64::from(flagged); + send.mute = true; + send.follows_mute = false; + vec![SessionEdit::UpsertRoute { route: send }] +} + +/// #1654 root ruling B19-R8 (MAJOR-1, MAJOR-2) and R19-5 on both hosts, on [`open_follow_model`]. +/// MAJOR-1: a live send move (`gain_db` to -3 dB over 4,000 samples, from block 4) is in flight +/// when a rebuild before block 6 adds a muted, inactive send: the move keeps its own length (every +/// block is the never-rebuilt run's, bit for bit), and the rebuild completes +/// `TRANSITION_FALLBACK` (one watermark, count 1), the conservative term. The same rebuild before +/// block 44 (the move long finished) completes `EXACT`. MAJOR-2: one transaction before block 4 +/// moves `gain_db` over 4,000 samples and adds the muted send: it completes `TRANSITION_FALLBACK` +/// from its own send move alone; the rebuild alone completes `EXACT`. Every block both hosts +/// render the same bits. +/// +/// Test value: red if either host completes a rebuild that carries a following send `EXACT` while +/// a live send move (an earlier commit's or the transaction's own) may still ramp, if one is +/// flagged with no live send move, if the carry cuts the move, or if the hosts disagree. +#[cfg(feature = "test-support")] +#[test] +fn a_rebuild_carrying_a_following_send_across_a_live_send_move_is_flagged_alike_on_both_hosts() { + const RUN: u64 = 48; + const RAMP: u32 = 4_000; + let model = open_follow_model(); + let unrelated = muted_send_rebuild(&model); + let hand = send_row_edit(1, -3.0, Some(RAMP)); + let mut same_transaction = hand.clone(); + same_transaction.extend(unrelated.iter().cloned()); + let moved = both_run(&model, RUN, &[(4, hand.clone(), EditPath::Live)]); + // (label, live move block, rebuild block, rebuild edits, flagged) + for (label, live, rebuild, edits, flagged) in [ + ("MAJOR-1", Some(4), 6, &unrelated, true), + ( + "MAJOR-1, the move long finished", + Some(4), + 44, + &unrelated, + false, + ), + ("MAJOR-2", None, 4, &same_transaction, true), + ("MAJOR-2, no move", None, 4, &unrelated, false), + ] { + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + for block in 0..RUN { + if live == Some(block) { + both_commit(&mut browser, &mut c_abi, &hand, EditPath::Live, label); + } + if block == rebuild { + both_commit(&mut browser, &mut c_abi, edits, EditPath::Rebuild, label); + } + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{label}, block {block}: the two hosts agree"); + if live.is_some() { + assert_eq!( + b, moved[block as usize], + "{label}, block {block}: the never-rebuilt run's bits" + ); + } + if block == rebuild { + let watermark = c_abi.watermark(); + assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); + assert_eq!( + status_watermark(&browser), + watermark, + "{label}: one watermark" + ); + let outcome = if flagged { + capi::OUTCOME_TRANSITION_FALLBACK + } else { + capi::OUTCOME_EXACT + }; + assert_eq!(watermark.2, outcome, "{label}: the rebuild's outcome"); + } + } assert_eq!( ( browser.host.watermark().transition_fallback_count, c_abi.transition_fallback_count() ), - (count, count), - "{label}: the transition fallback count" + (u64::from(flagged), u64::from(flagged)), + "{label}: the fallback count" ); + } +} + +/// #1654 root ruling B19-R9 (the B19-R8 check's repro) on both hosts, on [`open_follow_model`] +/// (480-sample landings at quantum 128). A live send gain move to -3 dB from 512 over 200 samples +/// is in flight when R1 (the direct route `t1-main` to -3 dB, block 5) rebuilds: it adopts at 640 +/// flagged (count 1). R2 (`t1-main` to -6 dB, block 7, sample 896) comes after the move ended but +/// inside R1's at-once landing: `TRANSITION_FALLBACK` (count 2). R3 (`t1-main` to -9 dB, block +/// 16), after every landing ended with no live send move: `EXACT` (count stays 2). Every block +/// both hosts render the same bits. +/// +/// Test value: red if either host's flagged rebuild does not fold its own at-once landing into the +/// send move window (R2 reads `EXACT`), if the window outlives that landing (R3 flagged), or if +/// the hosts disagree. +#[cfg(feature = "test-support")] +#[test] +fn a_second_rebuild_inside_a_flagged_rebuilds_landing_is_flagged_alike_on_both_hosts() { + const RUN: u64 = 24; + assert_eq!(QUANTUM, 128); + let model = open_follow_model(); + let direct = |db: f32| { + let mut route = model + .routes + .iter() + .find(|route| route.id.as_str() == "t1-main") + .expect("the direct route") + .clone(); + route.gain_db = db; + vec![SessionEdit::UpsertRoute { route }] }; - // Gate 7. - let twin = both_run(&model, RUN, &[]); + let hand = send_row_edit(1, -3.0, Some(200)); + // (label, rebuild block, edits, outcome, fallback count) + let rebuilds = [ + ("R1", 5, direct(-3.0), capi::OUTCOME_TRANSITION_FALLBACK, 1), + ("R2", 7, direct(-6.0), capi::OUTCOME_TRANSITION_FALLBACK, 2), + ("R3", 16, direct(-9.0), capi::OUTCOME_EXACT, 2), + ]; let mut browser = Rig::new(&model); let mut c_abi = CAbi::new(&model); - same_blocks(&mut browser, &mut c_abi, 0..REBUILD, "gate 7"); - both_commit( - &mut browser, - &mut c_abi, - &[added_route], - EditPath::Rebuild, - "gate 7", - ); - for block in REBUILD..RUN { + for block in 0..RUN { + if block == 4 { + both_commit( + &mut browser, + &mut c_abi, + &hand, + EditPath::Live, + "the hand move", + ); + } + let rebuild = rebuilds.iter().find(|rebuild| rebuild.1 == block); + if let Some((label, _, edits, _, _)) = rebuild { + both_commit(&mut browser, &mut c_abi, edits, EditPath::Rebuild, label); + } let (b, c) = (browser.step(block), c_abi.step(block)); - assert_eq!(b, c, "gate 7, block {block}: the two hosts agree"); - assert_eq!( - b, twin[block as usize], - "gate 7, block {block}: the never-rebuilt run" - ); - } - outcome(&mut browser, &c_abi, "gate 7", false); - // R19-5. - for (label, edits, offset) in [ - ("the gain offset", send_edit(1, 3.0, Some(RAMP)), 3.0), - ("matrix_lr", send_edit(4, 0.5, Some(RAMP)), 0.0), - ] { - let moved = both_run(&model, RUN, &[(MOVE, edits.clone(), EditPath::Live)]); - let mut browser = Rig::new(&model); - let mut c_abi = CAbi::new(&model); - same_blocks(&mut browser, &mut c_abi, 0..MOVE, label); - both_commit(&mut browser, &mut c_abi, &edits, EditPath::Live, label); - same_blocks(&mut browser, &mut c_abi, MOVE..REBUILD, label); - both_commit( - &mut browser, - &mut c_abi, - &[moved_ride(offset)], - EditPath::Rebuild, - label, - ); - let end = (MOVE * u64::from(QUANTUM) + u64::from(RAMP)) / u64::from(QUANTUM); - for block in REBUILD..end { - let (b, c) = (browser.step(block), c_abi.step(block)); - assert_eq!(b, c, "{label}, block {block}: the two hosts agree"); + assert_eq!(b, c, "block {block}: the two hosts agree"); + if let Some((label, _, _, outcome, fallbacks)) = rebuild { + let watermark = c_abi.watermark(); + assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); assert_eq!( - b, moved[block as usize], - "{label}, block {block}: the move runs" + status_watermark(&browser), + watermark, + "{label}: one watermark" + ); + assert_eq!( + watermark.1, + block * u64::from(QUANTUM), + "{label}: adopted at its submit block" + ); + assert_eq!(watermark.2, *outcome, "{label}: the rebuild's outcome"); + assert_eq!( + ( + browser.host.watermark().transition_fallback_count, + c_abi.transition_fallback_count() + ), + (*fallbacks, *fallbacks), + "{label}: the fallback count" ); } - // B19-R8: the move may still ramp at the rebuild, so it is flagged. - outcome(&mut browser, &c_abi, label, true); - } -} - -/// #1654 U2 fold 1, MAJOR-3 on both hosts. `t1-b` keeps a flat `gain_db` ride at -6 dB (its table -/// continues) and a rebuild before block 4 removes either its flat `rr` entry at 0.5 or `t1`'s -/// mute entry (held muted): the send stays a following route, no join record moves its cell and no -/// cursor hears the removed curve. Each rebuild completes `EXACT` on both hosts, and from the -/// first block boundary after the landing over the removed row's own length (`SendMatrix`, 240, -/// for `rr`; the mute length, 480, for the mute curve) both hosts render a fresh plan of the -/// committed model, bit for bit. -/// -/// Test value: red if a removed row or source mute curve of a send that stays seven-word leaves -/// the send on the removed curve's last value on either host, or if the hosts disagree. -#[cfg(feature = "test-support")] -#[test] -fn removing_a_send_row_or_its_source_mute_curve_lands_alike_on_both_hosts() { - use session::{AutomationShape, ParameterUnit}; - const RUN: u64 = 24; - const EDIT: u64 = 4; - let end = u64::from(QUANTUM) * BLOCKS; - let flat = |value: f32, shape, unit| vec![send_piece(shape, (0, value), (end, value), unit)]; - let mut kept = follow_model(false, false); - kept.automation.push(send_entry( - "t1-b-gain", - 1, - flat(-6.0, AutomationShape::Linear, ParameterUnit::Db), - )); - let mut with_rr = kept.clone(); - with_rr.automation.push(send_entry( - "t1-b-rr", - 6, - flat(0.5, AutomationShape::Linear, ParameterUnit::Linear), - )); - let mut with_mute = follow_model(true, false); - with_mute.automation[0].segments = flat(1.0, AutomationShape::Step, ParameterUnit::Linear); - with_mute.automation.extend(kept.automation.iter().cloned()); - let fresh = both_run(&kept, RUN, &[]); - for (label, model, removed, length) in [ - ("rr", &with_rr, "t1-b-rr", 240_u64), - ("the source mute", &with_mute, "t1-mute", 480), - ] { - let edits = vec![SessionEdit::RemoveAutomation { - automation_id: id(removed), - }]; - let run = both_run(model, RUN, &[(EDIT, edits, EditPath::Rebuild)]); - assert_ne!( - run[..EDIT as usize], - fresh[..EDIT as usize], - "{label}: not vacuous, the removed curve is heard" - ); - let landed = (EDIT * u64::from(QUANTUM) + length).div_ceil(u64::from(QUANTUM)) as usize; - assert_eq!( - run[landed..], - fresh[landed..], - "{label}: the committed model's bits after the landing" - ); } } @@ -7725,392 +7501,3 @@ fn the_reach_pair_cap_bounds_vca_automation_alike_on_both_hosts() { b"host.resource.vca_reach_pairs\t$\n".to_vec() ); } - -/// #1654 root ruling B19-R7 on both hosts. `t1-b` hears flat `ll` and `rr` entries at 0.5; a hand -/// `gain_db` move to -6 dB over 1,000 samples (from block 4) is in flight when a rebuild before -/// block 6 removes the `ll` entry: the send's left column loses its last curve and lands at once -/// over `SendMatrix` (240), cutting the move there. On both hosts the rebuild completes -/// `TRANSITION_FALLBACK` (one watermark, count 1). Then: nothing (MAJOR-A), a second rebuild before -/// block 8 that adds a muted, inactive send (MAJOR-C: also `TRANSITION_FALLBACK` under B19-R8, the -/// move still ramps; count 2), or a session -/// seek before block 7 inside the move, forward or back (MAJOR-D). From the first block boundary -/// after the move's end both hosts render, bit for bit, a fresh plan of the committed model fed -/// and seeked alike. The same removal before block 24, the move long ended, completes `EXACT`. -/// -/// Test value: red if either host completes the cut move's rebuild `EXACT` (or flags a removal -/// with no move), if a removed row's value survives the landing, a seek or a further rebuild, or -/// if the hosts disagree. -#[cfg(feature = "test-support")] -#[test] -fn removing_a_send_row_across_a_live_send_move_is_flagged_alike_on_both_hosts() { - use session::{AutomationShape, ParameterUnit}; - const RUN: u64 = 40; - const MOVE: u64 = 4; - const RAMP: u32 = 1_000; - let q = u64::from(QUANTUM); - let end = q * BLOCKS; - let flat = |value: f32| { - vec![send_piece( - AutomationShape::Linear, - (0, value), - (end, value), - ParameterUnit::Linear, - )] - }; - let mut model = follow_model(false, false); - model.automation.push(send_entry("t1-b-ll", 3, flat(0.5))); - model.automation.push(send_entry("t1-b-rr", 6, flat(0.5))); - let remove = vec![SessionEdit::RemoveAutomation { - automation_id: id("t1-b-ll"), - }]; - let mut muted_send = model - .routes - .iter() - .find(|route| route.id.as_str() == "t1-b") - .expect("the send") - .clone(); - muted_send.id = id("t2-b"); - muted_send.source = session::RouteSource::Track { - track_id: id(T2.id), - tap: session::SendTap::PreFader, - }; - muted_send.mute = true; - muted_send.follows_mute = false; - let added = vec![SessionEdit::UpsertRoute { - route: muted_send.clone(), - }]; - // The committed model after the move and the removal. - let mut committed = model.clone(); - committed - .automation - .retain(|entry| entry.id.as_str() != "t1-b-ll"); - t1_b(&mut committed).gain_db = -6.0; - let mut committed_added = committed.clone(); - committed_added.routes.push(muted_send); - committed_added - .routes - .sort_by(|left, right| left.id.as_str().cmp(right.id.as_str())); - // (label, rebuild block, second rebuild, seek (block, timeline block), flagged) - for (label, rebuild, second, seek, flagged) in [ - ("MAJOR-A", 6, false, None, true), - ("MAJOR-C", 6, true, None, true), - ("MAJOR-D inside", 6, false, Some((7, 7)), true), - ("MAJOR-D forward", 6, false, Some((7, 20)), true), - ("MAJOR-D back", 6, false, Some((7, 2)), true), - ("the move ended", 24, false, None, false), - ] { - let mut browser = Rig::new(&model); - let mut c_abi = CAbi::new(&model); - let mut fresh = Rig::new(if second { &committed_added } else { &committed }); - let landed = (MOVE * q + u64::from(RAMP)) - .max(rebuild * q + 240) - .div_ceil(q); - let (mut generation, mut timeline) = (1_u64, 0_u64); - for block in 0..RUN { - if block == MOVE { - both_commit( - &mut browser, - &mut c_abi, - &send_edit(1, -6.0, Some(RAMP)), - EditPath::Live, - label, - ); - } - if block == rebuild { - both_commit(&mut browser, &mut c_abi, &remove, EditPath::Rebuild, label); - } - if second && block == rebuild + 2 { - both_commit(&mut browser, &mut c_abi, &added, EditPath::Rebuild, label); - } - if let Some((at, to)) = seek - && block == at - { - generation += 1; - timeline = to; - for host in [&mut browser.host, &mut fresh.host] { - assert_eq!( - host.session_seek(None, generation, to * q, None), - host_web::RESULT_OK - ); - } - c_abi.session_seek(generation, to * q, None); - } - let mut outputs = Vec::new(); - for leg in [&mut browser, &mut fresh] { - for (feed, strip) in [T1, T2, T3].into_iter().enumerate() { - let name = format!("src-{}", strip.id); - let [left, right] = planes(feed as u64, timeline); - assert_eq!( - leg.host.submit_source( - name.as_bytes(), - generation, - timeline * q, - leg.rate, - &[&left, &right], - QUANTUM, - false, - ), - host_web::RESULT_OK, - "{label}: browser source {name}" - ); - if outputs.is_empty() { - c_abi.submit_from(&name, feed as u64, timeline, timeline, generation); - } - } - 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(block); - assert_eq!(outputs[0], c, "{label}, block {block}: the two hosts agree"); - if block >= landed { - assert_eq!( - outputs[0], outputs[1], - "{label}, block {block}: the fresh plan after the landing" - ); - } - if block == rebuild { - // The block that adopts the removal. - let watermark = c_abi.watermark(); - assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); - assert_eq!( - status_watermark(&browser), - watermark, - "{label}: one watermark" - ); - let outcome = if flagged { - capi::OUTCOME_TRANSITION_FALLBACK - } else { - capi::OUTCOME_EXACT - }; - assert_eq!(watermark.2, outcome, "{label}: the removal's outcome"); - } - if second && block == rebuild + 2 { - // B19-R8: the move still ramps, so the second rebuild is flagged too. - assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); - assert_eq!( - c_abi.watermark().2, - capi::OUTCOME_TRANSITION_FALLBACK, - "{label}: the second rebuild" - ); - } - timeline += 1; - } - assert_eq!( - ( - browser.host.watermark().transition_fallback_count, - c_abi.transition_fallback_count() - ), - { - let count = u64::from(flagged) + u64::from(second); - (count, count) - }, - "{label}: the fallback count" - ); - } -} - -/// #1654 root ruling B19-R8 on both hosts. MAJOR-1: `t1-b` rides its `gain_db` (0 to -12 dB); -/// a hand gain move of `+3` over 4,000 samples (before block 4) is in flight when a rebuild before -/// block 6 adds a muted, inactive send: the rebuild carries the riding send, which render may land -/// at once at the swap, so it completes `TRANSITION_FALLBACK` (one watermark, count 1). The same -/// rebuild before block 44 (the move long finished) completes `EXACT` (with no hand move at all: -/// gate 7 of `send_rides_and_live_send_moves_cross_rebuilds_exactly_on_both_hosts`). -/// MAJOR-2: `t1-b` hears flat `ll`, `rl` and `rr` entries; one transaction before block 4 moves -/// `gain_db` to -6 dB over 4,000 samples, changes `rl` and removes `ll`: it completes -/// `TRANSITION_FALLBACK` from its own send move alone; without the move it completes `EXACT`. -/// Every block both hosts render the same bits. -/// -/// Test value: red if either host completes a rebuild that carries a following send `EXACT` while -/// a live send move (an earlier commit's or the transaction's own) may still ramp, if one is -/// flagged with no live send move, or if the hosts disagree. -#[cfg(feature = "test-support")] -#[test] -fn a_rebuild_carrying_a_following_send_across_a_live_send_move_is_flagged_alike_on_both_hosts() { - use session::{AutomationShape, ParameterUnit}; - const RUN: u64 = 48; - const RAMP: u32 = 4_000; - let end = u64::from(QUANTUM) * BLOCKS; - let flat = |value: f32| { - vec![send_piece( - AutomationShape::Linear, - (0, value), - (end, value), - ParameterUnit::Linear, - )] - }; - let mut riding = follow_model(false, false); - riding.automation.push(send_entry( - "t1-b-gain", - 1, - vec![send_piece( - AutomationShape::Linear, - (0, 0.0), - (end, -12.0), - ParameterUnit::Db, - )], - )); - let mut muted_send = t1_b(&mut riding.clone()).clone(); - muted_send.id = id("t2-b"); - muted_send.source = session::RouteSource::Track { - track_id: id(T2.id), - tap: session::SendTap::PreFader, - }; - muted_send.mute = true; - muted_send.follows_mute = false; - let unrelated = vec![SessionEdit::UpsertRoute { route: muted_send }]; - let mut rows = follow_model(false, false); - rows.automation.push(send_entry("t1-b-ll", 3, flat(0.5))); - rows.automation.push(send_entry("t1-b-rl", 5, flat(0.1))); - rows.automation.push(send_entry("t1-b-rr", 6, flat(0.5))); - let row_edits = vec![ - SessionEdit::UpsertAutomation { - automation: send_entry("t1-b-rl", 5, flat(0.3)), - }, - SessionEdit::RemoveAutomation { - automation_id: id("t1-b-ll"), - }, - ]; - let mut same_transaction = send_edit(1, -6.0, Some(RAMP)); - same_transaction.extend(row_edits.iter().cloned()); - let hand = send_edit(1, 3.0, Some(RAMP)); - // (label, model, live move block, rebuild block, rebuild edits, flagged) - for (label, model, live, rebuild, edits, flagged) in [ - ("MAJOR-1", &riding, Some(4), 6, &unrelated, true), - ( - "MAJOR-1, the move long finished", - &riding, - Some(4), - 44, - &unrelated, - false, - ), - ("MAJOR-2", &rows, None, 4, &same_transaction, true), - ("MAJOR-2, no move", &rows, None, 4, &row_edits, false), - ] { - let mut browser = Rig::new(model); - let mut c_abi = CAbi::new(model); - for block in 0..RUN { - if live == Some(block) { - both_commit(&mut browser, &mut c_abi, &hand, EditPath::Live, label); - } - if block == rebuild { - both_commit(&mut browser, &mut c_abi, edits, EditPath::Rebuild, label); - } - let (b, c) = (browser.step(block), c_abi.step(block)); - assert_eq!(b, c, "{label}, block {block}: the two hosts agree"); - if block == rebuild { - let watermark = c_abi.watermark(); - assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); - assert_eq!( - status_watermark(&browser), - watermark, - "{label}: one watermark" - ); - let outcome = if flagged { - capi::OUTCOME_TRANSITION_FALLBACK - } else { - capi::OUTCOME_EXACT - }; - assert_eq!(watermark.2, outcome, "{label}: the rebuild's outcome"); - } - } - assert_eq!( - ( - browser.host.watermark().transition_fallback_count, - c_abi.transition_fallback_count() - ), - (u64::from(flagged), u64::from(flagged)), - "{label}: the fallback count" - ); - } -} - -/// #1654 root ruling B19-R9 (the B19-R8 check's repro) on both hosts. `t1-b` rides its `gain_db` -/// (0 to -12 dB; 480-sample landings at quantum 128). A hand gain move of `+3` from 512 over 200 -/// samples is in flight when R1 (the direct route `t1-main` to -3 dB, block 5) rebuilds: it adopts -/// at 640 flagged (count 1). R2 (`t1-main` to -6 dB, block 7, sample 896) comes after the hand -/// move ended but inside R1's at-once landing: `TRANSITION_FALLBACK` (count 2). R3 (`t1-main` to -/// -9 dB, block 16), after every landing ended with no live send move: `EXACT` (count stays 2). -/// Every block both hosts render the same bits. -/// -/// Test value: red if either host's flagged rebuild does not fold its own at-once landing into the -/// send move window (R2 reads `EXACT`), if the window outlives that landing (R3 flagged), or if -/// the hosts disagree. -#[cfg(feature = "test-support")] -#[test] -fn a_second_rebuild_inside_a_flagged_rebuilds_landing_is_flagged_alike_on_both_hosts() { - use session::{AutomationShape, ParameterUnit}; - const RUN: u64 = 24; - assert_eq!(QUANTUM, 128); - let end = u64::from(QUANTUM) * BLOCKS; - let mut model = follow_model(false, false); - model.automation.push(send_entry( - "t1-b-gain", - 1, - vec![send_piece( - AutomationShape::Linear, - (0, 0.0), - (end, -12.0), - ParameterUnit::Db, - )], - )); - let direct = |db: f32| { - let mut route = model - .routes - .iter() - .find(|route| route.id.as_str() == "t1-main") - .expect("the direct route") - .clone(); - route.gain_db = db; - vec![SessionEdit::UpsertRoute { route }] - }; - let hand = send_edit(1, 3.0, Some(200)); - // (label, rebuild block, edits, outcome, fallback count) - let rebuilds = [ - ("R1", 5, direct(-3.0), capi::OUTCOME_TRANSITION_FALLBACK, 1), - ("R2", 7, direct(-6.0), capi::OUTCOME_TRANSITION_FALLBACK, 2), - ("R3", 16, direct(-9.0), capi::OUTCOME_EXACT, 2), - ]; - let mut browser = Rig::new(&model); - let mut c_abi = CAbi::new(&model); - for block in 0..RUN { - if block == 4 { - both_commit( - &mut browser, - &mut c_abi, - &hand, - EditPath::Live, - "the hand move", - ); - } - let rebuild = rebuilds.iter().find(|rebuild| rebuild.1 == block); - if let Some((label, _, edits, _, _)) = rebuild { - both_commit(&mut browser, &mut c_abi, edits, EditPath::Rebuild, label); - } - let (b, c) = (browser.step(block), c_abi.step(block)); - assert_eq!(b, c, "block {block}: the two hosts agree"); - if let Some((label, _, _, outcome, fallbacks)) = rebuild { - let watermark = c_abi.watermark(); - assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); - assert_eq!( - status_watermark(&browser), - watermark, - "{label}: one watermark" - ); - assert_eq!( - watermark.1, - block * u64::from(QUANTUM), - "{label}: adopted at its submit block" - ); - assert_eq!(watermark.2, *outcome, "{label}: the rebuild's outcome"); - assert_eq!( - ( - browser.host.watermark().transition_fallback_count, - c_abi.transition_fallback_count() - ), - (*fallbacks, *fallbacks), - "{label}: the fallback count" - ); - } - } -} From 194fe34df776c4715033d3f5ad004385ef2ef3ee Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 18:15:09 +0000 Subject: [PATCH 34/36] Name the composed lane's compose_toward target as defined behaviour (#1655 U3 MINOR-1) Co-Authored-By: Claude Opus 5.5 --- ...ender-stored-vca-offset-and-mute-automation.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md index 7f513a604..74ce46492 100644 --- a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md +++ b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md @@ -508,7 +508,7 @@ No edit under `crates/engine/src/realtime/`; no row class or table change (no SD `compose::tests::a_step_keeps_its_jump_length_while_another_curve_rides_on_a_held_grid_output`. 2. An offsets record on a composed lane takes the single cell's rule (seed, clear the deferral, every cursor holds to the jump's completion), not `Composed::retarget` (deferral then each - cursor's own `J`), so a composed lane moves as one cell does. + cursor's own `J`), so a composed lane moves as one cell does in holds and deferrals. 3. `own_db` is `Option`: today's host-core record sites write `None` (mechanical, correct for every lane that has a cell today); V3 writes `Some` on static-own event lanes. 4. `LaneFaderAutomation::new` returns `Result` (the cursor table allocates); bind @@ -743,7 +743,8 @@ SDK regeneration). that pulled ahead is replayed first, as for a hold. A jump that emits nothing changes nothing (#1711 U1 fold 2). It reaches every hold and deferral the lane put on every cursor: the record hold in `offsets_jump`, the adoption hold and deferral of `settle_adoption`, and the holds - `with_held_grid` propagates, so a composed lane moves as one cell does (V2 departure 2). + `with_held_grid` propagates, so a composed lane moves as one cell does in holds and deferrals + (V2 departure 2). - **Who uses it.** Only the fader composed lanes (`FaderCurve::prepared`: `.with_held_grid() .with_jump_release()`). The composed mute lanes have gate cursors only. The route column (`crates/graph`, #1654) does not call it and keeps its T3 rule: a held value follows the column's @@ -756,9 +757,13 @@ SDK regeneration). 1. The host-core live gate compares from the step's jump's end (3,240), not from 0. Before the step the composed lane and one cell over the sum differ: a record's ramp target composes each curve toward the ramp's completion clipped at that curve's own next discontinuity, where one cell clips - the sum at the lane's next discontinuity (`FaderComposite::toward` against `curve_toward`). That - rule is outside this finding (phase-2 placeholder in the fold report); the jump's end equalizes - the gains, and the defect window (3,328 to 9,024) lies after it. + the sum at the lane's next discontinuity (`FaderComposite::toward` against `curve_toward`). The + jump's end equalizes the gains, and the defect window (3,328 to 9,024) lies after it. + Defined behaviour: on a composed fader lane a record's or an adoption's ramp target composes each + curve toward the ramp's completion, each clipped at its own next discontinuity (K1 + `compose_toward`, #1649 D3), so between the record and another curve's next discontinuity the + lane differs from one cell over the summed curve, and that curve's jump makes them equal; a + successor plan uses the same rule. 2. The both-host gate runs at the native width only (control_parity's rule, V5); the two bank widths are the host-core twin's. From 2a39f5ed3e6ebcdaea848103c667e309aa33fb4b Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 18:32:39 +0000 Subject: [PATCH 35/36] Say the mute-only carry flag over-reports and cuts nothing (B19-R10 check MINORs) The B19-R10 check found every gate change at a carry bit-equal to the same change on one uninterrupted plan. Three graph comments that said the at-once landing cuts a move now say it is the uninterrupted gate change; TBD54 records that production routes reach the flag only as an over-report. Comments and spec only. Co-Authored-By: Claude Opus 5.5 --- ...-removed-send-row-exactly-across-a-live-move.md | 8 ++++++-- crates/graph/src/runtime.rs | 14 ++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md b/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md index 300f1e75b..675f78447 100644 --- a/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md +++ b/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md @@ -37,8 +37,12 @@ keeps its own length, whatever else the rebuild changes. - Root ruling B19-R10 (batch 19b): send-row rendering (route rows 1-6, the send-gain offset) is not shipped; #1654 stays open in phase 2 behind this issue and turns send rows on after it. The route-op machinery (the merged walk, the carry, the conservative flag) shipped with batch 19b - for its production callers, source-mute and VCA-mute following routes, so the cut and the flag - above are reached by those routes today. + for its production callers, source-mute and VCA-mute following routes. Those routes reach the + flag only as an over-report: the B19-R10 check found that every gate change at a carry is + bit-equal to the same gate change on one uninterrupted plan, so no mute-only or VCA-mute carry + cuts a live send move (W1 and W2 below need send-row value cursors). Under W1 a removed muting + curve opens the column over the live move's own ramp rather than the curve's own length, as a + live record inside a landing does on one plan (recorded, not a cut). - The last check of the whole send flag term (B19-R9, batch 19) found two more MAJORs, both of the B19-R7/R8/R9 window: - MAJOR-W1: a live send move committed while a rebuild is pending (submitted, not yet adopted) diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index 9ec45d6b5..1a1df082b 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -2113,9 +2113,10 @@ impl LiveFollowRoute { /// the curves that close the column, for an open among all of them: the mute length for the /// own mute, `VcaMute` for a VCA mute, R19-4). A column that no longer hears a follow curve of /// its predecessor (U2 fold 1, MAJOR-3) lands at once over the removed curve's own length (the - /// longer one with a coincident gate move, R19-4), also with a column ramp in flight, which - /// it cuts (B19-R7: the control plane marks such a rebuild `TransitionFallback` while a live - /// send move may still ramp; TBD54 lands it exactly). Any other move lands at once over the + /// longer one with a coincident gate move, R19-4), also with a column ramp in flight + /// (B19-R7). With send rows off this is the column jump an uninterrupted gate change makes; + /// the control plane still marks such a rebuild `TransitionFallback` while a live send move + /// may ramp, a conservative over-report (TBD54). Any other move lands at once over the /// mute length. No landing state survives the carry. A column with no curve compares its /// cell's target the same way. fn place_carried(&mut self, at: i64) { @@ -2216,7 +2217,8 @@ impl LiveFollowRoute { } else { // A gate move coincident with a removed curve's landing takes the longer length // (R19-4); a move with no gate move lands over the removed curve's length (or the - // mute length), cutting a move in flight (B19-R7, flagged by the control plane). + // mute length), as an uninterrupted gate change does (B19-R7; the control + // plane's flag over-reports it). let length = gate_length.map_or(landing, |length| core::cmp::max(length, dropped)); ramps.retarget_column( column, @@ -19871,8 +19873,8 @@ mod follow_curve_tests { /// B19-R7 (a removed curve with a live move in flight): the left lane's own mute curve, which /// holds the left column shut, is removed at a swap (1,280) while a live send move (gain 1.0 /// -> 0.5 over 2,000 from 1,152) runs on the right column. Column 0 lands at once on the moved - /// cell's words over the mute length (200), a column jump that would cut a move in flight - /// there (the control plane flags such a rebuild `TransitionFallback`); column 1, which lost + /// cell's words over the mute length (200), the column jump an uninterrupted gate change + /// makes (the control plane flags such a rebuild `TransitionFallback`); column 1, which lost /// nothing, keeps its move. An open curve's removal composes the adopted target and moves /// nothing. /// From c29e63ea754a08fe46b7aca8472227de7559d17e Mon Sep 17 00:00:00 2001 From: BL Date: Sat, 17 Oct 2026 19:12:02 +0000 Subject: [PATCH 36/36] Number the batch 19b placeholder: #1718 Co-Authored-By: Claude Opus 5.5 --- ...-stored-send-gain-mute-and-matrix-automation.md | 14 +++++++------- ...removed-send-row-exactly-across-a-live-move.md} | 4 ++-- crates/capi/include/miso_engine_v1.h | 2 +- crates/capi/src/abi.rs | 2 +- crates/control-plane/src/control.rs | 6 +++--- crates/graph/src/runtime.rs | 2 +- crates/host-core/src/prepare.rs | 6 +++--- docs/C_ABI_V1_QUALIFICATION.md | 2 +- hosts/host-web/tests/control_parity.rs | 2 +- .../web/miso-engine-v1-audio-worklet-host.d.ts | 2 +- sdk/src/browser/shipped-host.d.ts | 2 +- 11 files changed, 22 insertions(+), 22 deletions(-) rename .github/ISSUE_SPECS/{TBD54-land-a-removed-send-row-exactly-across-a-live-move.md => 1718-land-a-removed-send-row-exactly-across-a-live-move.md} (97%) diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 7fb1f3671..d362ffcc2 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -14,8 +14,8 @@ head). Batch R2, after #1651-#1653. - **The route-op machinery shipped with batch 19b**: the following route op on K1's merged walk (T3), its carry, the U2 fold fixes and the conservative B19-R7/R8/R9 transition-fallback term. Its production callers are source-mute and VCA-mute following routes (#1652, #1655). -- **This issue stays open in phase 2 behind TBD54.** It turns send rows on (route cells, flags - bits 6-11, the send-gain offset and the gain law, the send-row gates) after TBD54 lands the +- **This issue stays open in phase 2 behind #1718.** It turns send rows on (route cells, flags + bits 6-11, the send-gain offset and the gain law, the send-row gates) after #1718 lands the cut exactly. The decision records below describe the batch 19 attempt; the B19-R10 record at the end lists what was removed. @@ -868,7 +868,7 @@ read `size_of`, so no pinned number moved. Realtime policy: 171 marked regions i ## Decision record: B19-R7 split (batch 19) Root ruling B19-R7 after the U2 fold 2 FAIL: the owed landing is removed and the cut it leaves is -flagged conservatively. TBD54 lands the exact case and deletes the flag. Nothing changed under +flagged conservatively. #1718 lands the exact case and deletes the flag. Nothing changed under `crates/engine/src/realtime/`; no row class or table changed (no SDK regeneration). - **Render (`crates/graph/src/runtime.rs`).** Deleted `FollowCursors::owed`, `walk_follow`, @@ -906,7 +906,7 @@ flagged conservatively. TBD54 lands the exact case and deletes the flag. Nothing miso_engine_v1.h`, `docs/C_ABI_V1_QUALIFICATION.md`, `hosts/host-web/web/miso-engine-v1-audio- worklet-host.d.ts`, `sdk/src/browser/shipped-host.d.ts`: "It also reports a rebuild that carries a send into one that no longer hears one of its stored curves while a live send move may still - ramp (conservative; TBD54 removes it)." + ramp (conservative; #1718 removes it)." Gates (all green): - Graph: `a_removed_send_row_lands_at_once_across_a_move_in_flight` (replaces fold 1's @@ -966,12 +966,12 @@ Control plane only: no render edit, nothing under `crates/engine/src/realtime/` is on, or which send. A fully applied move whose window has not yet passed `first_sample + ramp` by the render clock also flags. Example: R19-5's reposition across a live move keeps the move's length bit for bit and is now flagged. A rebuild with no live send move in the window and no - ramped send record of its own completes `EXACT`. TBD54 removes the cut and the flag. + ramped send record of its own completes `EXACT`. #1718 removes the cut and the flag. - **Doc (beside #1713's, one wording on the five surfaces):** `crates/capi/src/abi.rs`, `crates/capi/include/miso_engine_v1.h`, `docs/C_ABI_V1_QUALIFICATION.md`, `hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts`, `sdk/src/browser/shipped-host.d.ts`: "It also reports any rebuild that carries an automated following send while a live send move may - still ramp (conservative; TBD54 removes it)." + still ramp (conservative; #1718 removes it)." Gates (all green; red with the change reverted once): - C ABI: `an_unrelated_rebuild_across_a_live_send_move_on_a_riding_send_is_flagged_on_the_c_abi` @@ -1003,7 +1003,7 @@ Gates (all green; red with the change reverted once): On a route column, a value held by an earlier column jump follows the column's current free time and never moves earlier (R19-3 (d), T3). It is deterministic and equal to a fresh plan. A single fader cell differs: there, a later step's jump replaces the ramp and shortens the hold, so the grid resumes after -that step's own length. Unifying the two is phase 2 (TBD54, "Related"). +that step's own length. Unifying the two is phase 2 (#1718, "Related"). ## Decision record: B19-R9 (batch 19) diff --git a/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md b/.github/ISSUE_SPECS/1718-land-a-removed-send-row-exactly-across-a-live-move.md similarity index 97% rename from .github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md rename to .github/ISSUE_SPECS/1718-land-a-removed-send-row-exactly-across-a-live-move.md index 675f78447..a3bb75014 100644 --- a/.github/ISSUE_SPECS/TBD54-land-a-removed-send-row-exactly-across-a-live-move.md +++ b/.github/ISSUE_SPECS/1718-land-a-removed-send-row-exactly-across-a-live-move.md @@ -1,7 +1,7 @@ # Land a removed send row exactly across a live move -Placeholder from batch 19 (#1654 U2 review, root ruling B19-R7; controls coordinator, 2026-10-17). -Phase 2. Root files it. It follows the "split a blocking gate early" practice: #1654 closes on its +Filed by root from batch 19 (#1654 U2 review, root ruling B19-R7; controls coordinator, 2026-10-17). +Phase 2. It follows the "split a blocking gate early" practice: #1654 closes on its passing gates with a conservative flag, and the exact case moves here. ## Product outcome diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index 8c2e7b165..51e34d89b 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 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; #1713 removes it). It also * reports any rebuild that carries an automated following send while a live send move may still - * ramp (conservative; TBD54 removes it). The host + * ramp (conservative; #1718 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 914b15729..923892dbe 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 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; /// #1713 removes it). It also reports any rebuild that carries an automated following send while -/// a live send move may still ramp (conservative; TBD54 removes it). +/// a live send move may still ramp (conservative; #1718 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/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 2d83ae4a2..574923487 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -647,7 +647,7 @@ pub struct SessionState { /// superseded nothing. pub(crate) observation_forwarded: Vec, /// The live send moves that may still ramp, as one conservative window (root ruling B19-R7, - /// the B17-R14 pattern; TBD54 deletes it): `None` when none may. + /// the B17-R14 pattern; #1718 deletes it): `None` when none may. pub(crate) send_moves: Option, } @@ -2505,7 +2505,7 @@ where // #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(); - // B19-R7/B19-R8 (TBD54 deletes them): the same, for any carried following route that + // B19-R7/B19-R8 (#1718 deletes them): the same, for any carried following route that // stays following, and for one that loses a curve (its landing joins the window). let follow_into_following = inventory.carries_a_following_route_into_a_following_route(); let follow_loses_a_curve = inventory.carries_a_following_route_that_loses_a_curve(); @@ -2599,7 +2599,7 @@ where // transition fallback, whether or not a column moves at the swap: conservative, it // over-reports. // - // B19-R8 (TBD54 deletes this; it widens B19-R7's removed-curve term): a successor whose + // B19-R8 (#1718 deletes this; it widens B19-R7's removed-curve term): a successor whose // send join carries a following route into a following route may land a carried column // at once at the swap (a removed follow curve, or a column that composes otherwise), // cutting a live send move still ramping there, which render cannot diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index 1a1df082b..099d210d3 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -2116,7 +2116,7 @@ impl LiveFollowRoute { /// longer one with a coincident gate move, R19-4), also with a column ramp in flight /// (B19-R7). With send rows off this is the column jump an uninterrupted gate change makes; /// the control plane still marks such a rebuild `TransitionFallback` while a live send move - /// may ramp, a conservative over-report (TBD54). Any other move lands at once over the + /// may ramp, a conservative over-report (#1718). Any other move lands at once over the /// mute length. No landing state survives the carry. A column with no curve compares its /// cell's target the same way. fn place_carried(&mut self, at: i64) { diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index a3a081541..23d145d32 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -772,7 +772,7 @@ impl PlanStateInventory { /// ruling B19-R8). `false` for a plan prepared without a predecessor and for a declared /// discontinuity, which carries nothing. /// - /// **Conservative (B19-R8; TBD54 removes it).** Render may land a carried column at once at + /// **Conservative (B19-R8; #1718 removes it).** Render may land a carried column at once at /// the swap (a removed follow curve, or a column that composes otherwise), so a live send /// move still ramping on it is cut. The control plane completes such a /// rebuild as [`engine::realtime::CandidateOutcome::TransitionFallback`] while a live send move @@ -6199,12 +6199,12 @@ fn resolve_route_carry<'a>( // 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; - // B19-R7 (TBD54 deletes it): a carried following route that stays seven-word and loses + // B19-R7 (#1718 deletes it): a carried following route that stays seven-word and loses // a source mute curve lands that column at once over the removed curve's length, cutting // a live send move in flight there. The join flags every such carry; // the control plane completes it as the fallback while a send move may still ramp. carry.follow_loses_a_curve |= follows && successor_follows && loses_a_curve(heard, hears); - // B19-R8 (TBD54 deletes it): any carried following route that stays seven-word may land + // B19-R8 (#1718 deletes it): any carried following route that stays seven-word may land // a column at once at the swap, cutting a live send move in flight there, whether or not // it loses a curve. The control plane completes it as the fallback while a send move may // still ramp. diff --git a/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index 2aca4c5e3..9a3b8bf27 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 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; #1713 removes it; #1652 U2a fold 2). It also reports any rebuild that carries an automated following send while a live send move may -still ramp (conservative; TBD54 removes it; #1654, root rulings B19-R7 and B19-R8). +still ramp (conservative; #1718 removes it; #1654, root rulings B19-R7 and B19-R8). 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 diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index eafe24e03..472835bd2 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -6501,7 +6501,7 @@ fn an_output_route_entry_is_refused_alike_on_both_hosts() { same_blocks(&mut browser, &mut c_abi, 1..3, "after the refusal"); } -/// Root ruling B19-R10 (send-row rendering is not shipped; #1654 turns it on behind TBD54). A +/// Root ruling B19-R10 (send-row rendering is not shipped; #1654 turns it on behind #1718). A /// session whose send `t1-b` stores automation on its own rows (a `gain_db` ride, a `mute` step, /// rides and flat entries on `ll`, `lr`, `rl` and `rr`) behaves exactly as the session without /// those entries, as on `main` before batch 19: on both hosts, with `t1-b` six-word (no source 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 aca75d684..30b0a6516 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 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; #1713 removes it). It also reports any rebuild that carries an automated following - /// send while a live send move may still ramp (conservative; TBD54 removes it). + /// send while a live send move may still ramp (conservative; #1718 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 aca75d684..30b0a6516 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 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; #1713 removes it). It also reports any rebuild that carries an automated following - /// send while a live send move may still ramp (conservative; TBD54 removes it). + /// send while a live send move may still ramp (conservative; #1718 removes it). /// @internal readonly appliedOutcome: bigint; /// The module's memory size now: at least the boot size (`MisoAudioWorkletHost.memoryBytes`),