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..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 @@ -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 #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. + ## Product outcome A producer's stored send rides play on every host: a route into a submix follows its stored @@ -395,3 +408,687 @@ 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. + +## 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). + +## 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). + +## 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. + +## 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. + +## 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. + +## 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. + +## 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. + +## 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. #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`, + `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; #1718 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. + +## 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`. #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; #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` + (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. + +## 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 (#1718, "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. + +## 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/1655-render-stored-vca-offset-and-mute-automation.md b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md index dc344e299..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 @@ -386,3 +386,411 @@ 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. + +## 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 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 + (`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`. + +## 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. + +## 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" (#1654 U2 fold 1 MAJOR-3) only when no own or VCA mute + curve is left; a dropped VCA mute lands at once over `VcaMute` at the carry (B19-R7). 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). + +## 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). + +## 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 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 + 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`). 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. + +**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/.github/ISSUE_SPECS/1718-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 new file mode 100644 index 000000000..a3bb75014 --- /dev/null +++ b/.github/ISSUE_SPECS/1718-land-a-removed-send-row-exactly-across-a-live-move.md @@ -0,0 +1,94 @@ +# Land a removed send row exactly across a live move + +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 + +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 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 + +- 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). +- 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 + 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. 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) + 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 + 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. +- 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). +- 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 (phase 2; its send rows turn on after this issue), #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. diff --git a/crates/automation/src/compose.rs b/crates/automation/src/compose.rs new file mode 100644 index 000000000..4db41239b --- /dev/null +++ b/crates/automation/src/compose.rs @@ -0,0 +1,835 @@ +//! 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: 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 +//! 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, 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. + 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, + /// Whether that event was not held (the cursor's own retarget). + moved: 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, + /// 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, + /// [`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 { + /// 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, + moved: false, + }); + } + Ok(Self { + cursors: table.into_boxed_slice(), + state: CellState::new(), + target: None, + completion, + block: None, + held_grid: false, + jump_release: 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 + } + + /// 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 +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, + // 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) + }) + } + } + } +} + +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); + } + + /// 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)); + 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 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, + 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; + self.block = other.block; + self.held_grid = other.held_grid; + self.jump_release = other.jump_release; + 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. + /// [`Self::start`] then [`Self::next_event`] until `None`. + pub fn walk( + &mut self, + program: &Program, + span: NodeSpan, + mode: SeekMode, + 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); + } + } + + /// 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() + .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 retargeted = !landing && event.is_some_and(|event| !event.held); + let free = if retargeted { + self.state.free_after(self.completion) + } 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)); + // `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; + 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 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) + }); + let defer = if column_jump { free } else { extend }; + // 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() || 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 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) + { + 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); + } + 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; + /// `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`: 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() { + let Some(event) = cursor.next.filter(|event| event.offset == offset) else { + continue; + }; + cursor.next = None; + 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; + } + 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..d8d6000dd --- /dev/null +++ b/crates/automation/src/compose/tests.rs @@ -0,0 +1,691 @@ +//! 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}"); + } +} + +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)] + ); + } +} + +/// 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)] + ); +} + +/// #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/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 3004a7e2b..fafa384cc 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. @@ -216,6 +266,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 { @@ -328,46 +457,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`. @@ -375,8 +485,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); @@ -411,8 +520,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; } @@ -439,10 +547,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)); +} diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index ed3e6af7e..3a63b05d6 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,351 @@ 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, 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, + /// 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() + .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 { + 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 +1202,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 +1241,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 +1252,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 +1344,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 +1366,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 +1420,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 +1446,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 +1461,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 +1563,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 +1682,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 +1748,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 +1778,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 +1836,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 +1863,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 +1919,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 +1949,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 +1999,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 +2023,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 +2062,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 +2081,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 +2174,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 +2276,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 +2284,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 +2350,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 +2378,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 +2387,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 +2466,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 +2476,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 +2487,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 +2560,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 +2582,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 +2635,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 +2730,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 +2868,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 +2881,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 +2890,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 +2969,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 +3106,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 +3180,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 +3230,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, @@ -2497,6 +3269,10 @@ struct FaderAutomationPlan { program_bytes: u64, /// Each strip that has an automated fader or mute lane, by track ID. strips: BTreeMap, PreparedStripAutomation>, + /// 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 { @@ -2525,9 +3301,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) }) } @@ -2538,6 +3314,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 @@ -2563,6 +3380,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 @@ -2575,6 +3400,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)), } } @@ -2583,9 +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. `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. 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 @@ -2621,48 +3449,118 @@ 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()) { + // #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 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`. + 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)); } strips.insert( Box::::from(strip.id.as_str()), @@ -2673,40 +3571,166 @@ 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() { + 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], + }); + } + strips + .entry(Box::::from(strip.id.as_str())) + .or_insert(PreparedStripAutomation { + fader: [None, None], + mute: [None, None], + }) + .mute = cells; + } + // #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. + 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); } - let mut cells = [None, None]; - for (lane, entry) in entries.into_iter().enumerate() { - let Some(entry) = entry else { continue }; - let segments = controls::stored_segments(entry); - // Defensive: the session validator refuses an entry with no segment. - let value = segments - .first() - .ok_or_else(|| refuse("host.automation.mute", entry))? - .start_value; - let cell = u32::try_from(tables.len()) - .map_err(|_| diag("host.automation.mute", "$.automation"))?; - cells[lane] = Some(PreparedMuteCell { - cell, - value, - terms: values.mute[lane], - }); - tables.push((entry, segments, controls::MUTE_CELL_RULE)); - } - strips - .entry(Box::::from(strip.id.as_str())) - .or_insert(PreparedStripAutomation { - fader: [None, None], - mute: [None, None], - }) - .mute = cells; + prepared.vca = cells.into_boxed_slice(); } 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 +3748,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, + vcas, })) } @@ -5805,16 +6823,27 @@ 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) .then(|| (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 @@ -5993,7 +7022,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, @@ -6180,14 +7209,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 @@ -6195,7 +7241,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 }) } @@ -6276,17 +7323,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), ) } } @@ -6479,7 +7530,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() } @@ -7750,17 +8801,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 { @@ -16185,7 +17253,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"); @@ -18571,4 +19639,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/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index 4fc865294..51e34d89b 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 any rebuild that carries an automated following send while a live send move may still + * 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 202d2a5b5..923892dbe 100644 --- a/crates/capi/src/abi.rs +++ b/crates/capi/src/abi.rs @@ -95,7 +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). +/// #1713 removes it). It also reports any rebuild that carries an automated following send while +/// 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/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index 16dc2c576..58384083c 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 @@ -11518,3 +11540,341 @@ fn an_edit_of_a_following_route_is_live() { ); } } + +/// #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(), + } +} + +/// 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![session::AutomationSegment { + shape, + start_sample: 0, + end_sample: SOURCE_FRAMES, + start_value: value, + end_value: value, + unit, + }] +} + +/// 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, + ) +} + +/// 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.push(lane_mute_entry( + "eq0-mute", + session::ParameterChannel::Both, + 0.0, + )); + }) +} + +/// [`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 } +} + +/// 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 +/// 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 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 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_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)); + }); + 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("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" + ); +} + +/// #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, 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_following_send_is_flagged_on_the_c_abi() { + const RAMP: u32 = 4_000; + 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 [ + ( + "gain_db in flight", + send_row_edit("gain_db", -3.0, Some(RAMP)), + 2, + true, + ), + ( + "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)), + 40, + false, + ), + ] { + let mut rig = Rig::new(&document); + let mut twin = Rig::new(&document); + for _ in 0..4 { + 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 direct = direct_route_edit(&mut rig, -3.0); + let outcome = if flagged { + crate::OUTCOME_TRANSITION_FALLBACK + } else { + crate::OUTCOME_EXACT + }; + let (adoption, live) = rebuild_and_adopt_as(&mut rig, &[direct], 48, Some(outcome)); + assert_eq!( + 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. 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_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", -3.0, Some(4_000))); + } + 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)); + assert_eq!( + 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 [`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(&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 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), + ("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_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!( + fallback_count(&rig), + fallbacks, + "{label}: the fallback count" + ); + } +} 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 5923812d0..574923487 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -646,6 +646,56 @@ 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; #1718 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 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 +/// 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 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 { + 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 @@ -2455,6 +2505,11 @@ 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 (#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(); + let follow_retarget_ramp = inventory.following_route_retarget_ramp(); let mut candidate_provider = ProviderEpoch::candidate( sources, inventory, @@ -2543,7 +2598,43 @@ 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-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 + // 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_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 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 => { + 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 +2829,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); @@ -3131,9 +3224,9 @@ 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 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; let mut resolved_routes = Vec::new(); @@ -3149,9 +3242,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 { @@ -3386,6 +3477,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); @@ -3401,6 +3505,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/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/controls/src/automation.rs b/crates/controls/src/automation.rs index 4daf68a25..6a26c6d36 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -15,7 +15,9 @@ use crate::effect_rows::{has_rows, session_unit}; use crate::error::ControlError; use crate::lane::Side; use crate::registry::resolve; +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. @@ -45,21 +47,20 @@ 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 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 { - matches!(target.owner, Owner::Strip(_)) - && target.section == Section::Builtins - && target.control == FADER_DB + 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`: `[-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. +/// 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, @@ -116,9 +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` 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 +/// 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 @@ -389,16 +391,116 @@ 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, #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): the mute row gives no -/// lane a fader cell or a hand-move offset. +/// 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) + 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 +/// 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 a strip's builtin `mute` row (row 6): the rendered row whose entry gives a @@ -429,7 +531,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 @@ -439,20 +541,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() { @@ -522,6 +628,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 { @@ -533,11 +648,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. @@ -567,14 +720,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 @@ -587,23 +748,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/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 afa3fa98c..ba7271f9b 100644 --- a/crates/controls/src/lib.rs +++ b/crates/controls/src/lib.rs @@ -47,9 +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, fader_automation, fader_row, filter_statics_changed_under_automation, - filter_under_automation, mute_automation, mute_row, rendered_automation_changed, - stored_segments, + continued_mute_lanes, fader_automation, fader_event_lanes, fader_row, + filter_statics_changed_under_automation, filter_under_automation, mute_automation, + 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 7ac5a6e88..6411c46d9 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 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. @@ -119,7 +123,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 @@ -131,13 +162,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 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::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..2ab06a2b8 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 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}; @@ -95,10 +96,30 @@ impl<'m> OwnerEdits<'m> { { self.take_fader_automation(strip); } + 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 c8a9160e5..8306bd4a9 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 #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, } @@ -241,7 +243,20 @@ pub fn owner_rows_in( OwnerSlot::Route(index) => { route::push_rows(out, view.route(index), 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 => {} } @@ -586,6 +601,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() @@ -593,6 +614,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 1803baca9..60033be83 100644 --- a/crates/controls/src/view.rs +++ b/crates/controls/src/view.rs @@ -120,6 +120,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 b59acc2b7..4a1536412 100644 --- a/crates/controls/tests/automation.rs +++ b/crates/controls/tests/automation.rs @@ -887,3 +887,499 @@ 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 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()); +} + +/// #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/graph/src/lib.rs b/crates/graph/src/lib.rs index cfad04414..570257bff 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -1141,7 +1141,7 @@ pub(crate) const FOLLOW_ROUTE_CELL_WORDS: usize = 7; /// /// `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 (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. @@ -1354,15 +1354,50 @@ 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; +/// 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 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 own mute. + pub mute: u32, + /// The `VcaMute` row's length: a reaching VCA's mute curve (#1655 D4). + pub vca_mute: 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 => self.mute, + GraphRouteCursorRow::VcaMute => self.vca_mute, + } + } + // REALTIME_POLICY_END +} + +/// The jump lengths of a following route's cursor rows, resolved from the block's three session +/// 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; -/// 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. +/// 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 @@ -1373,41 +1408,67 @@ pub type GraphMuteJumpLength = fn(GraphJumpLengths) -> u32; 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) arrival: u64, - pub(crate) mute_jump: GraphMuteJumpLength, + pub(crate) jumps: GraphRouteJumpRule, pub(crate) flags: RouteFollowFlags, + /// 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], } 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 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); `arrival` is `a(tap)`; `jumps` resolves the cursor + /// rows' jump lengths; `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 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] pub fn new( program: std::sync::Arc, cells: [Option; 2], + vca_mutes: [Vec; 2], arrival: u64, - mute_jump: GraphMuteJumpLength, + jumps: GraphRouteJumpRule, 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 in_program = cells .iter() .flatten() + .chain(vca_mutes.iter().flatten()) .all(|&cell| program.cell(cell as usize).is_some()); - (automated != [false; 2] && in_program && flags.automated == automated).then_some(Self { + (automated.contains(&true) && in_program && flags.automated == automated).then(|| Self { program, cells, + vca_mutes: vca_mutes.map(Vec::into_boxed_slice), arrival, - mute_jump, + jumps, flags, + continued_vca: [false; 2], }) } + /// 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 + } + /// `a(tap)`, the arrival the route's events are timed at. #[must_use] pub const fn arrival(&self) -> u64 { @@ -1429,8 +1490,12 @@ 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 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. + pub(crate) columns: runtime::FollowColumns, } /// The control-plane half of one following route's seven-word cell (#1652 D1): the counterpart of @@ -1453,6 +1518,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 { @@ -1507,16 +1576,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; - ( + let (cursor_bytes, cursor_largest) = runtime::follow_route_cursor_bytes(&source); + Ok(( GraphFollowRouteControlBinding { node: GraphNodeId::Route { route_id: route_id.clone(), @@ -1528,6 +1602,7 @@ pub(crate) fn follow_route_control_pair( superseded: superseded.clone(), }), source, + columns, }, GraphFollowRouteControlProducer { route_id: route_id.as_str().into(), @@ -1538,8 +1613,10 @@ 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, }, - ) + )) } /// Why [`PreparedGraphPlan::attach_route_controls`] refused. @@ -1550,6 +1627,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 @@ -1578,7 +1658,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. @@ -1646,6 +1728,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( @@ -1654,7 +1740,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::< @@ -2727,7 +2817,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, @@ -2817,7 +2909,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; @@ -4959,6 +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 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 { @@ -10487,9 +10584,12 @@ 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` length for both rows. + pub(crate) fn route_jumps(lengths: GraphJumpLengths) -> GraphRouteJumpLengths { + GraphRouteJumpLengths { + mute: lengths.mute_samples, + vca_mute: lengths.mute_samples, + } } /// A program of one mute-shaped cell per given lane (steps, completion `End`, block jump @@ -10524,8 +10624,15 @@ 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, + [Vec::new(), Vec::new()], + arrival, + route_jumps, + flags, + ) + .expect("a valid source") } /// A step curve: `values[i]` from `times[i]` on (the first value from the start). @@ -10545,9 +10652,9 @@ mod follow_route_tests { } /// A source refuses no automated lane, a cell outside its program and `automated` flags that - /// disagree with its cells. + /// disagree with its cells (own or reaching VCA mute cells, #1655 D4). /// - /// Test value: red if a route with no automated lane takes the seven-word render state, or if + /// 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() { @@ -10556,17 +10663,40 @@ 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_with = |cells, vca_mutes, flags| { GraphFollowRouteSource::new( std::sync::Arc::clone(&program), cells, + vca_mutes, 0, - mute_length, + route_jumps, flags, ) .is_some() }; + 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]], both), + "a lane only a VCA mute reaches" + ); + assert!( + new_with([None, None], [vec![0, 0], vec![0]], both), + "VCA mute cells alone" + ); + assert!( + !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()], both), + "VCA flags that disagree" + ); assert!(!new([None, None], RouteFollowFlags::default()), "no lane"); assert!(!new([Some(1), None], flags), "a cell outside the program"); assert!( @@ -10581,6 +10711,20 @@ mod follow_route_tests { ); } + /// 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] + fn each_cursor_row_takes_its_own_jump_length() { + let lengths = GraphRouteJumpLengths { + mute: 3, + vca_mute: 4, + }; + assert_eq!(lengths.for_row(GraphRouteCursorRow::SourceMute), 3); + assert_eq!(lengths.for_row(GraphRouteCursorRow::VcaMute), 4); + } + /// 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. @@ -10692,7 +10836,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, @@ -10770,6 +10915,7 @@ mod follow_route_tests { activity.1, ACTIVITY_ROUTES, ) + .expect("tables") .1 }) .collect(); @@ -10785,11 +10931,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 bbf569697..099d210d3 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -1273,17 +1273,18 @@ 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; #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 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`]: 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, @@ -1296,12 +1297,12 @@ pub(crate) struct LiveFollowRoute { /// 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` ([`FollowCursors::automated`]), never the record's. + /// automated lanes (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,28 +1368,35 @@ 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.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 { + 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)) + { *ramp = IndexedRamp::new(start, target, length); *position = 0; } @@ -1436,20 +1444,181 @@ 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 mute row's jump-length rule. +/// One source column's composed target: its two coefficients (its [`COLUMNS`] entries) and +/// whether its gate silences it. +type ColumnTarget = ([f32; 2], bool); + +/// 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 { + /// 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. + vca_count: usize, +} + +impl ColumnRoles { + /// 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. + 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.own.is_some_and(|index| value(index) != 0.0) + || self.vca().any(|index| value(index) != 0.0) + } +} + +/// 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; + 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(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 { + own, + 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 (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 (cells, _) = column_cells(source, column); + let count = cells.len(); + 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 whether its VCA +/// mute cursors continue at a carried first block. +struct RouteColumn { + walk: automation::Composed, + probe: automation::Composed, + roles: ColumnRoles, + /// 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`]). + /// + /// # 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, roles) = column_cells(source, column); + if cells.is_empty() { + return Ok(None); + } + let table = + || automation::Composed::new(&source.program, &cells, automation::Completion::End); + Ok(Some(Self { + walk: table()?, + probe: table()?, + roles, + vca_continued: true, + })) + } +} + +/// 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)?, + ])) + } +} + +/// 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)` and the cursor rows' jump-length rule. struct FollowCursors { program: std::sync::Arc, - /// `[L, R]`; `None` for a lane the plan does not automate. - cells: [Option; 2], - states: [automation::CellState; 2], - /// Per lane, the curve value the route has heard (`value != 0`), from the seed on; 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, - mute_jump: crate::GraphMuteJumpLength, + jumps: crate::GraphRouteJumpRule, + /// 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 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). + 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], /// How the route's next block begins: fresh, carried or running. start: FollowStart, } @@ -1457,70 +1626,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, + vca_mute: 0, + }, + start: None, }; } +/// 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, +} + 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. `columns` are the source's walks + /// ([`FollowColumns::bind`]). pub(crate) fn new( transform: crate::RouteTransform, control: Box, source: &crate::GraphFollowRouteSource, + columns: FollowColumns, ) -> 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: columns.0, + silent: [false; 2], arrival: source.arrival, - mute_jump: source.mute_jump, + jumps: source.jumps, + continued_vca: source.continued_vca, + dropped: [false; 2], + dropped_vca: [false; 2], + follow_heard: [false; 2], start: FollowStart::Fresh, }; - let (target, mute) = follow.compose(&transform, source.flags, curve); + let composition = Composition { + transform: &transform, + flags: source.flags, + }; + 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,56 +1723,144 @@ 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) +} + +/// 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 } = 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]; + if let Some(slot) = follow_zeroed.get_mut(column) { + *slot = zeroed; + } + let all = gated_route_coefficients( + transform, + crate::RouteGate { + mute, + follow_zeroed, + }, + ); + (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) + }) } +} - /// [`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 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] { - 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) + .is_some_and(RouteColumn::heard_follow) + }) + } + + /// 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.mute_jump)(lengths), 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), + vca_mute: bound(rule.vca_mute), + }; + for column in self.columns.iter_mut().flatten() { + 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() { + if let Some(cursor) = column.walk.cursor_mut(index) { + cursor.set_jump(lengths.vca_mute); + } + } + } let idle = FollowBlock { - spans: [None; 2], - jump, + lengths, + ..FollowBlock::IDLE }; let Some(span) = time.node_span(self.arrival) else { - return (idle, start, placed); + return (idle, start); }; let length = i64::from(frames); if frames == 0 @@ -1591,23 +1869,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)) @@ -1615,96 +1881,126 @@ 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); } - change(offset, *curve, set); } } +/// 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)) + }) +} + 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`. + /// + /// 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) -> usize { + fn apply_cell(&mut self, snapshot: LiveSnapshot, at: Option) -> usize { let mut applied = 0; if let Some(record) = self.control.read(snapshot) { self.transform = record.transform(); self.flags = record.flags(); - let (target, mute) = - self.follow - .compose(&self.transform, self.flags, self.follow.curve); - self.mute = mute; - self.ramps.retarget(target, record.length()); + let length = record.length(); + let Self { + ramps, + mute, + transform, + flags, + follow, + .. + } = self; + let composition = Composition { + transform, + flags: *flags, + }; + for column in 0..COLUMNS.len() { + let target = 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"))] @@ -1714,108 +2010,331 @@ 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]) { + /// 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. + 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); + // #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 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()); + *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; + } + 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_continued = vca_continues; + let Some(index) = walk.roles.own else { + continue; + }; + 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); + } + } + } + } 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), 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 + /// (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 (#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) { + let Self { + ramps, + mute, + transform, + flags, + follow, + block, + .. + } = self; + let composition = Composition { + transform, + flags: *flags, + }; + 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 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 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); + ramps.retarget_column( + column, + with_column(ramps.target(), column, target.0), + landing, + ); + if let Some(silent) = follow.silent.get_mut(column) { + *silent = target.1; + } + 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 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 { + 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) + }) + }; + 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))) { + 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) { + walk.walk.seed_target(target); + } 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), 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, + 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; + } + } + *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); + let carried = match (start, block.start) { + (FollowStart::Carried, Some(at)) => Some(at), + _ => None, + }; + self.apply_cell(snapshot, block.start); + if let Some(at) = carried { + 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, + .. + } = follow; + let composition = Composition { + transform, + flags: *flags, + }; + 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, @@ -1827,27 +2346,41 @@ impl LiveFollowRoute { } = self; let FollowCursors { program, - cells, - states, - curve, + columns, + silent, .. } = follow; - let jump = block.jump; - follow_walk( + let composition = Composition { + transform, + flags: *flags, + }; + 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; + } + 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(offset as usize, frames); + 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; }, ); @@ -5713,14 +6246,17 @@ 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, + /// 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 @@ -5737,7 +6273,14 @@ 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) + 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); @@ -5752,9 +6295,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 @@ -5781,18 +6324,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.mute_jump)), - ) + 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) => { @@ -5801,9 +6340,16 @@ 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(from) = follow else { 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; @@ -5818,7 +6364,8 @@ fn carry_route( prepared, mute: prepared_mute, zeroed, - mute_jump, + jumps, + vca, }); true } @@ -6398,8 +6945,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], @@ -7069,6 +7616,7 @@ pub(crate) struct RuntimeParts { ( Box, crate::GraphFollowRouteSource, + FollowColumns, ), >, pub(crate) bindings: BTreeMap>>, @@ -7235,7 +7783,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 } @@ -7287,11 +7840,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) { @@ -8693,9 +9246,9 @@ fn live_route_rows( }; // #1652 D3: a following route is listed as a six-word one is, so the successor's // join carries it by route ID whatever its kind in either plan. - let (route, 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, [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 { @@ -8705,6 +9258,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() @@ -17751,11 +18305,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 { @@ -18855,3 +19411,939 @@ 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 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, 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 ([`route_jumps`] maps it from the mute length). + 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, + }) + } + } + + 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' 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], + 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 { + route: Box, + producer: crate::GraphFollowRouteControlProducer, + gate: RevisionGate, + pin: GatePin, + revision: u64, + } + + impl Send { + /// A route of `transform` over `curves`, at `curves.arrival`, following its source. + fn new(curves: &Curves, transform: RouteTransform) -> Self { + Self::continuing_vca(curves, transform, [false; 2]) + } + + /// [`Self::new`] whose source lanes `continued_vca` continue the predecessor's VCA mute + /// tables (#1655 D6). + fn continuing_vca( + curves: &Curves, + transform: RouteTransform, + continued_vca: [bool; 2], + ) -> Self { + let all: Vec<&Vec> = curves + .source + .iter() + .flatten() + .chain(curves.vca.iter().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 vca_mutes = curves.vca.each_ref().map(|lane| { + lane.iter() + .map(|_| { + next += 1; + next - 1 + }) + .collect::>() + }); + let flags = RouteFollowFlags { + follows_mute: true, + automated: [0, 1].map(|lane| cells[lane].is_some() || !vca_mutes[lane].is_empty()), + ..RouteFollowFlags::default() + }; + let source = GraphFollowRouteSource::new( + program, + cells, + vca_mutes, + curves.arrival, + send_jumps, + flags, + ) + .expect("source") + .with_continued_vca_mutes(continued_vca); + let (binding, producer) = crate::follow_route_control_pair( + &StableGraphId::parse("send").expect("ID"), + &LiveValuesSuperseded::new(), + source, + 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 { + route, + producer, + gate, + pin, + revision: 0, + } + } + + /// 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, 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` 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(driver)); + if self + .route + .begin_block(self.gate.snapshot(), &time) + .is_some() + { + self.route.mix(left, right); + } + } + + /// 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_with(first, LENGTHS, &Timeline, &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.5, + 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) + } + + /// 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. + 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()) + } + + /// 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 coincident gate events take one curve's length regardless, or retarget twice. + #[test] + fn coincident_follow_curve_events_take_the_longest_jump() { + let curves = Curves { + source: [Some(step(1_000, 0.0, 1.0)), None], + vca: [vec![step(1_000, 0.0, 1.0)], Vec::new()], + ..Curves::default() + }; + 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!( + aims(&jump.ramps[0], closed, longest, 0), + "mute {mute}: {:?} over {}", + jump.ramps[0].target, + jump.ramps[0].length + ); + 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-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 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_live_send_move_in_flight_at_a_swap_keeps_its_length() { + let curves = Curves { + source: [Some(step(4_000, 0.0, 1.0)), None], + vca: [Vec::new(), vec![step(4_000, 0.0, 1.0)]], + ..Curves::default() + }; + 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!( + successor.run(index * 64), + twin.run(index * 64), + "block {index}" + ); + } + } + + /// 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 the column stays on the removed curve's last value, or lands over another length. + #[test] + fn a_removed_follow_curve_lands_the_column_on_its_static_value() { + let right = Some(flat(0.0)); + let cases = [ + ( + Curves { + source: [Some(flat(1.0)), right.clone()], + ..Curves::default() + }, + 200, + ), + ( + Curves { + source: [None, right.clone()], + vca: [vec![flat(1.0)], Vec::new()], + ..Curves::default() + }, + VCA_MUTE, + ), + ]; + let after = Curves { + source: [None, right], + ..Curves::default() + }; + 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::new(&after, OPEN); + 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}"); + } + } + + /// 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), 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. + /// + /// Red if the removal never lands, or lands late (an owed landing), or touches column 1. + #[test] + 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 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(&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 + /// 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, + }) + } + } + + /// 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 landing is dropped by a seek or by the second rebuild and leaves the removed + /// curve's value. + #[test] + 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 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 + ); + } + } + } + + /// 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 curves = Curves { + source: [Some(step(1_000, 0.0, 1.0)), None], + ..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.write_with(OPEN, muted, 200); + 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.write_with(OPEN, unmuted, 200); + 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!( + 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 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 { + source: [None, Some(flat(0.0))], + ..Curves::default() + }; + let after = Curves { + source: [Some(flat(1.0)), Some(flat(0.0))], + ..Curves::default() + }; + let moved = RouteTransform { gain: 1.0, ..OPEN }; + let mut predecessor = moving(&before, OPEN, moved); + let record = predecessor.route.ramps.ramps; + let mut successor = Send::new(&after, moved); + 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" + ); + } + + /// #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 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, [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()], + ..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 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()], + ..Curves::default() + }; + let after = Curves { + source: [None, right], + ..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/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..95f6555d3 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_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_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, 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 +/// 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 @@ -1274,11 +1282,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() } @@ -1301,8 +1319,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 { @@ -1311,8 +1328,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 5f523015a..23d145d32 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 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], + id: &str, + record: &graph::FollowRouteControlRecord, +) -> Option { + follow_route_producer_position(producers, id) + .filter(|&index| producers[index].automated() == record.flags().automated) +} + /// 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 @@ -737,6 +752,45 @@ 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 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 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] + 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; #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 + /// 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. + #[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 @@ -808,6 +862,15 @@ 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, + /// 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. @@ -1099,6 +1162,9 @@ 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(), input_floors: Box::default(), raised_arrivals: Box::default(), @@ -1132,6 +1198,9 @@ 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(), input_floors: self.input_floors.clone(), raised_arrivals: self.raised_arrivals.clone(), @@ -2876,25 +2945,48 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( // `automated` is the plan's. The arrival `a(tap)` is the compile report's, below. let mut follow_cells = Vec::new(); for route in &model.routes { - if !follows_automated_mute(model, route) { + 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 { - 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]), }; - let flags = graph::RouteFollowFlags { - mute: route.mute, - follows_mute: route.follows_mute, - terms: effective.strips[source].mute, - automated: cells.map(|cell| cell.is_some()), + // #1655 D4: a source lane's curve is its own mute cell, then the reaching automated VCA + // 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, + route_source_id(route), + &mut program, + ) + .ok_or_else(|| graph_failure("host.route_control.attach"))?; + let Some(program) = program else { + return Err(graph_failure("host.route_control.attach")); }; - follow_cells.push((route.id.as_str(), (program, cells, flags))); + let curves = [0, 1].map(|lane| cells[lane].is_some() || !vca_mutes[lane].is_empty()); + if curves != automated { + return Err(graph_failure("host.route_control.attach")); + } + let flags = follow_route_flags(route, effective.strips[source].mute, automated); + // #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, vca_mutes, flags, continued_vca), + )); } let mut artifact = GraphCompiler::compile_with_builtins(GraphBuiltinsCompileRequest { dispatch: backend, @@ -2949,21 +3041,25 @@ 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, vca_mutes, flags, continued_vca) = + sources.remove(route_id.as_str())?; let node = GraphNodeId::Route { route_id: route_id.clone(), }; graph::GraphFollowRouteSource::new( program, cells, + vca_mutes, graph::floored_input_arrival(&arrivals, &node), - follow_mute_jump, + follow_route_jumps, flags, ) + .map(|source| source.with_continued_vca_mutes(continued_vca)) }) .map_err(|error| match error { graph::GraphRouteControlError::AlreadyAttached - | graph::GraphRouteControlError::BoundRoute => { + | graph::GraphRouteControlError::BoundRoute + | graph::GraphRouteControlError::CursorTable(_) => { graph_failure("host.route_control.attach") } }) @@ -3664,6 +3760,9 @@ 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(), input_floors, raised_arrivals, @@ -4359,10 +4458,70 @@ impl SendRecord { } } +/// 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. `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: &mut Option>, +) -> 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())?; + 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]) { + (Some(_), Some(cell)) => list.push(cell), + (None, None) => {} + _ => return None, + } + } + } + Some(lanes) +} + +/// 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: [bool; 2], +) -> graph::RouteFollowFlags { + graph::RouteFollowFlags { + mute: route.mute, + follows_mute: route.follows_mute, + terms, + 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 (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`. /// /// # Errors /// @@ -4378,12 +4537,7 @@ pub(crate) fn follow_route_record( let transform = graph_compiler::route_values(route.gain_db, [matrix.ll, matrix.lr, matrix.rl, matrix.rr]) .map_err(|_| LiveRebuild::Domain)?; - let flags = graph::RouteFollowFlags { - mute: route.mute, - follows_mute: route.follows_mute, - terms, - automated, - }; + let flags = follow_route_flags(route, terms, automated); graph::FollowRouteControlRecord::new(transform, flags, length).ok_or(LiveRebuild::Domain) } @@ -4481,9 +4635,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. /// @@ -4553,13 +4708,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 /// @@ -4567,27 +4754,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: static_own.then(|| own_db(own[1], lane)), }); } } @@ -4595,18 +4789,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 @@ -4768,9 +4968,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` @@ -4877,9 +5092,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 @@ -4897,8 +5118,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), ) } @@ -4936,16 +5158,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); @@ -5065,18 +5287,21 @@ 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, +/// #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() } -/// 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). +/// 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, @@ -5089,24 +5314,38 @@ 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) -} - -/// 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( + 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)) + }; + lanes.contains(&true).then_some(lanes) +} + +/// 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 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, lengths.mute_samples, lengths.pan_samples, - ) - .for_row(LiveRampRow::Mute) + ); + graph::GraphRouteJumpLengths { + mute: ramps.for_row(LiveRampRow::Mute), + vca_mute: ramps.for_row(LiveRampRow::VcaMute), + } } // REALTIME_POLICY_END @@ -5870,12 +6109,10 @@ 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, @@ -5913,6 +6150,22 @@ 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 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 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 @@ -5925,11 +6178,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; @@ -5940,12 +6199,25 @@ 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 (#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 (#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. + carry.follow_into_following |= follows && successor_follows; 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)); } } @@ -6689,6 +6961,7 @@ mod tests { route_id: route.into(), unit, following: false, + 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)]; @@ -6754,6 +7027,7 @@ mod tests { route_id: route.into(), unit, following, + automated: [false; 2], }; let join = |id| RouteJoin { id, retarget: None }; // (case, predecessor following, successor following, flagged) @@ -6775,6 +7049,73 @@ 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 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 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 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, + following, + automated, + }; + // (case, predecessor, successor, flagged) + for (case, before, after, flagged) in [ + ("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, + ), + ( + "swaps a lane", + live(1, true, left), + live(2, true, right), + true, + ), + ( + "seven -> six", + live(1, true, left), + live(2, false, none), + false, + ), + ( + "six -> seven", + live(1, false, none), + live(2, true, left), + false, + ), + ] { + let carry = resolve_route_carry( + &[before], + &[after], + &[RouteJoin { + id: "a-main", + retarget: None, + }], + ); + 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}"); + } + } + /// #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 @@ -6832,6 +7173,9 @@ 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(), input_floors: Box::default(), raised_arrivals: Box::default(), diff --git a/crates/host-core/tests/live_delta.rs b/crates/host-core/tests/live_delta.rs index 771a1f8cd..1cbe967c9 100644 --- a/crates/host-core/tests/live_delta.rs +++ b/crates/host-core/tests/live_delta.rs @@ -5381,3 +5381,55 @@ 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"); } + +/// #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 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_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 = 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(), [true, false]); + + 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, 5] { + 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/crates/host-core/tests/route_mute.rs b/crates/host-core/tests/route_mute.rs index bec298485..4c77dd245 100644 --- a/crates/host-core/tests/route_mute.rs +++ b/crates/host-core/tests/route_mute.rs @@ -2529,3 +2529,71 @@ fn the_following_route_charge_covers_its_attach_and_bind() { resources.total_bytes ); } + +/// #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 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 removing_a_source_mute_curve_of_a_following_send_lands_on_the_fresh_plan() { + use follow::*; + 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 + }; + 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 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 + .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 + M as usize, QUANTUM); + assert_bits_equal( + &send[landed..BLOCKS * QUANTUM], + &fresh[landed..BLOCKS * QUANTUM], + &format!("{what}: the fresh plan after the landing"), + ); + } +} diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index a393cd8e4..6934cff6d 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, }, ); } @@ -3298,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(); @@ -3468,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_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, }, ); } 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..d368cdb29 --- /dev/null +++ b/crates/host-core/tests/stored_vca_automation.rs @@ -0,0 +1,1692 @@ +//! 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}"); + } + } +} + +// --------------------------------------------------------------------------------------------- +// #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::*; + 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); + } + + /// #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/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index 5be40e410..9a3b8bf27 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 any rebuild that carries an automated following send while a live send move may +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 and a wrong-size struct, `header_smoke.cpp` pins the size and every field offset, and diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index 94545ac21..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 and mute 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,7 +306,31 @@ 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 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 +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. 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 @@ -336,14 +360,15 @@ 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 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" 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; @@ -351,7 +376,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 9763e128b..472835bd2 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| { @@ -6392,3 +6428,1076 @@ fn solo_and_a_vca_mute_compose_with_the_curve_for_a_following_send_on_both_hosts } } } + +/// 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_row_entry( + name: &str, + row: u32, + shape: session::AutomationShape, + (from, to): (f32, f32), + unit: session::ParameterUnit, +) -> session::Automation { + session::Automation { + id: id(name), + target: address( + Owner::Route(id("t1-b")), + Section::Own, + row, + ParameterChannel::Both, + ), + segments: vec![session::AutomationSegment { + shape, + start_sample: 0, + end_sample: u64::from(QUANTUM) * BLOCKS, + start_value: from, + end_value: to, + unit, + }], + offset: session::AutomationOffset::default(), + } +} + +/// #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_row_entry( + "t1-main-gain", + 1, + session::AutomationShape::Linear, + (-6.0, -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"); +} + +/// 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 +/// 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 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 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(); + send.id = id("t2-b"); + send.source = session::RouteSource::Track { + track_id: id(T2.id), + tap: session::SendTap::PreFader, + }; + 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() + ), + (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 }] + }; + 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); + 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" + ); + } + } +} + +// --------------------------------------------------------------------------------------------- +// #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() + ); +} 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..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 @@ -781,7 +781,8 @@ export interface MisoStatus { /// The watermark's outcome flags, the C ABI's bits: `EXACT` 1, `TRANSITION_FALLBACK` 2, /// `SUPERSEDED` 4. `TRANSITION_FALLBACK` also reports a 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 any rebuild that carries an automated following + /// 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 bdce6d935..30b0a6516 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -781,7 +781,8 @@ export interface MisoStatus { /// The watermark's outcome flags, the C ABI's bits: `EXACT` 1, `TRANSITION_FALLBACK` 2, /// `SUPERSEDED` 4. `TRANSITION_FALLBACK` also reports a 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 any rebuild that carries an automated following + /// 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`),